For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://modelgates.ai/docs/_mcp/server.
Skills Loader
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
bash
pnpm add @modelgates/sdk zodCreate a skills directory:
bash
mkdir -p ~/.claude/skills/pdf-processingmkdir -p ~/.claude/skills/data-analysismkdir -p ~/.claude/skills/code-reviewBasic Skills Tool
typescript
import { ModelGates, tool } from '@modelgates/agent';import { readFileSync, existsSync, readdirSync } from 'fs';import path from 'path';import { z } from 'zod'; const modelgates = new ModelGates({ apiKey: process.env.MODELGATES_API_KEY,}); const SKILLS_DIR = path.join(process.env.HOME || '~', '.claude', 'skills'); // List available skillsconst listAvailableSkills = (): string[] => { if (!existsSync(SKILLS_DIR)) return []; return readdirSync(SKILLS_DIR, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()) .filter((dirent) => existsSync(path.join(SKILLS_DIR, dirent.name, 'SKILL.md'))) .map((dirent) => dirent.name);}; const skillsTool = tool({ name: 'Skill', description: `Load a specialized skill to enhance the assistant's capabilities.Available skills: ${listAvailableSkills().join(', ') || 'none configured'}Each skill provides domain-specific instructions and capabilities.`, inputSchema: z.object({ type: z.string().describe("The skill type to load (e.g., 'pdf-processing')"), }), outputSchema: z.string(), // This is where the magic happens - modify context for next turn nextTurnParams: { input: (params, context) => { // Prevent duplicate skill loading const skillMarker = `[Skill: ${params.type}]`; if (JSON.stringify(context.input).includes(skillMarker)) { return context.input; } // Load the skill's instructions const skillPath = path.join(SKILLS_DIR, params.type, 'SKILL.md'); if (!existsSync(skillPath)) { return context.input; } const skill = readFileSync(skillPath, 'utf-8'); const skillDir = path.join(SKILLS_DIR, params.type); // Inject skill context into the conversation const currentInput = Array.isArray(context.input) ? context.input : [context.input]; return [ ...currentInput, { role: 'user', content: `${skillMarker}Base directory for this skill: $ $`, }, ]; }, }, execute: async (params, context) => { const skillMarker = `[Skill: ${params.type}]`; // Check if already loaded if (JSON.stringify(context?.turnRequest?.input || []).includes(skillMarker)) { return `Skill ${params.type} is already loaded`; } const skillPath = path.join(SKILLS_DIR, params.type, 'SKILL.md'); if (!existsSync(skillPath)) { const available = listAvailableSkills(); return `Skill "${params.type}" not found. Available skills: ${available.join(', ') || 'none'}`; } return `Launching skill ${params.type}`; },});Usage
typescript
const result = modelgates.callModel({ model: 'anthropic/claude-sonnet-4.5', input: 'I need to process a PDF and extract tables from it', tools: [skillsTool],}); const text = await result.getText();// The model will call the Skill tool, loading pdf-processing context// Subsequent responses will have access to the skill's instructionsExample Skill File
Create ~/.claude/skills/pdf-processing/SKILL.md:
markdown
# PDF Processing Skill You are now equipped with PDF processing capabilities. ## Available ToolsWhen processing PDFs, you have access to:- `extract_text`: Extract all text from a PDF- `extract_tables`: Extract tables as structured data- `extract_images`: Extract embedded images- `split_pdf`: Split PDF into individual pages ## Best Practices1. Always check PDF file size before processing2. For large PDFs (>50 pages), process in chunks3. OCR may be needed for scanned documents4. Tables may span multiple pages - handle accordingly ## Output Formats- Text: Plain text or markdown- Tables: JSON, CSV, or markdown tables- Images: PNG with sequential naming ## Error Handling- If a PDF is encrypted, request the password- If OCR fails, suggest alternative approaches- Report page numbers for any extraction errorsExtended: Multi-Skill Loader
Load multiple skills in a single call:
typescript
const multiSkillLoader = tool({ name: 'load_skills', description: 'Load multiple skills at once for complex tasks', inputSchema: z.object({ skills: z.array(z.string()).describe('Array of skill names to load'), }), outputSchema: z.object({ loaded: z.array(z.string()), failed: z.array( z.object({ name: z.string(), reason: z.string(), }) ), }), nextTurnParams: { input: (params, context) => { let newInput = Array.isArray(context.input) ? context.input : [context.input]; for (const skillName of params.skills) { const skillMarker = `[Skill: ${skillName}]`; // Skip if already loaded if (JSON.stringify(newInput).includes(skillMarker)) { continue; } const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md'); if (!existsSync(skillPath)) { continue; } const skillContent = readFileSync(skillPath, 'utf-8'); const skillDir = path.join(SKILLS_DIR, skillName); newInput = [ ...newInput, { role: 'user', content: `${skillMarker}Base directory: $ $`, }, ]; } return newInput; }, }, execute: async ({ skills }) => { const loaded: string[] = []; const failed: Array<{ name: string; reason: string }> = []; for (const skill of skills) { const skillPath = path.join(SKILLS_DIR, skill, 'SKILL.md'); if (existsSync(skillPath)) { loaded.push(skill); } else { failed.push({ name: skill, reason: 'Skill not found' }); } } return { loaded, failed }; },}); // Usageconst result = modelgates.callModel({ model: 'anthropic/claude-sonnet-4.5', input: 'I need to analyze a PDF report and create visualizations', tools: [multiSkillLoader],});// Model might call: load_skills({ skills: ['pdf-processing', 'data-analysis'] })Extended: Skill with Options
Skills that accept configuration:
typescript
const configurableSkillLoader = tool({ name: 'configure_skill', description: 'Load a skill with custom configuration options', inputSchema: z.object({ skillName: z.string(), options: z .object({ verbosity: z.enum(['minimal', 'normal', 'detailed']).default('normal'), strictMode: z.boolean().default(false), outputFormat: z.enum(['json', 'markdown', 'plain']).default('markdown'), }) .optional(), }), outputSchema: z.object({ status: z.enum(['loaded', 'already_loaded', 'not_found']), message: z.string(), configuration: z.record(z.unknown()).optional(), }), nextTurnParams: { input: (params, context) => { const skillMarker = `[Skill: ${params.skillName}]`; if (JSON.stringify(context.input).includes(skillMarker)) { return context.input; } const skillPath = path.join(SKILLS_DIR, params.skillName, 'SKILL.md'); if (!existsSync(skillPath)) { return context.input; } const skillContent = readFileSync(skillPath, 'utf-8'); const options = params.options || {}; // Build configuration header const configHeader = `## Skill Configuration- Verbosity: ${options.verbosity || 'normal'}- Strict Mode: ${options.strictMode || false}- Output Format: ${options.outputFormat || 'markdown'}`; const currentInput = Array.isArray(context.input) ? context.input : [context.input]; return [ ...currentInput, { role: 'user', content: `${skillMarker}$ $`, }, ]; }, // Adjust model behavior based on skill temperature: (params, context) => { // Lower temperature for strict mode if (params.options?.strictMode) { return 0.3; } return context.temperature; }, }, execute: async ({ skillName, options }) => { const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md'); if (!existsSync(skillPath)) { return { status: 'not_found' as const, message: `Skill "${skillName}" not found`, }; } return { status: 'loaded' as const, message: `Skill "${skillName}" loaded with configuration`, configuration: options || {}, }; },});Skill Discovery Tool
List and describe available skills:
typescript
const skillDiscoveryTool = tool({ name: 'list_skills', description: 'List all available skills with their descriptions', inputSchema: z.object({ category: z.string().optional().describe('Filter by category'), }), outputSchema: z.object({ skills: z.array( z.object({ name: z.string(), description: z.string(), hasConfig: z.boolean(), }) ), totalCount: z.number(), }), execute: async ({ category }) => { const availableSkills = listAvailableSkills(); const skills = []; for (const skillName of availableSkills) { const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md'); const content = readFileSync(skillPath, 'utf-8'); // Extract first paragraph as description const lines = content.split('\n').filter((l) => l.trim()); const description = lines.find((l) => !l.startsWith('#')) || 'No description'; // Check for config file const configPath = path.join(SKILLS_DIR, skillName, 'config.json'); const hasConfig = existsSync(configPath); skills.push({ name: skillName, description: description.slice(0, 100), hasConfig, }); } return { skills, totalCount: skills.length, }; },});Complete Example
Putting it all together:
typescript
import { ModelGates, tool, stepCountIs } from '@modelgates/agent';import { readFileSync, existsSync, readdirSync } from 'fs';import path from 'path';import { z } from 'zod'; const modelgates = new ModelGates({ apiKey: process.env.MODELGATES_API_KEY,}); const SKILLS_DIR = path.join(process.env.HOME || '~', '.claude', 'skills'); // ... (include skillsTool, multiSkillLoader, skillDiscoveryTool from above) // Use all skill tools togetherconst result = modelgates.callModel({ model: 'anthropic/claude-sonnet-4.5', input: `I have a complex task:1. First, show me what skills are available2. Load the appropriate skills for PDF analysis3. Then help me extract and analyze data from report.pdf`, tools: [skillDiscoveryTool, skillsTool, multiSkillLoader], stopWhen: stepCountIs(10),}); const text = await result.getText();console.log(text);Key Patterns
1. Idempotency
Always check if a skill is already loaded:
typescript
nextTurnParams: { input: (params, context) => { const marker = `[Skill: ${params.type}]`; if (JSON.stringify(context.input).includes(marker)) { return context.input; // Don't add again } // ... add skill },},2. Graceful Fallbacks
Handle missing skills gracefully:
typescript
execute: async (params) => { if (!existsSync(skillPath)) { return `Skill not found. Available: ${listAvailableSkills().join(', ')}`; } // ...},3. Context Preservation
Always preserve existing input:
typescript
nextTurnParams: { input: (params, context) => { const currentInput = Array.isArray(context.input) ? context.input : [context.input]; return [...currentInput, newMessage]; // Append, don't replace },},4. Clear Markers
Use unique markers to identify injected content:
typescript
const skillMarker = `[Skill: $]`;// Makes detection reliable and content clearly labeledSee Also
- nextTurnParams Guide - Context injection patterns
- Dynamic Parameters - Adaptive behavior
- Tools - Multi-turn orchestration