Skip to main content

Why nextTurnParams?

Traditional tool execution returns results to the model, but sometimes you need more:
  • Skills/Plugins: Load domain-specific instructions when a skill is activated
  • Progressive Context: Build up context as tools are used
  • Adaptive Behavior: Adjust model parameters based on tool results
  • Clean Separation: Tools manage their own context requirements
With nextTurnParams, tools can modify any callModel parameter for the next turn.

Basic Example

import { tool } from '@openrouter/agent';
import { z } from 'zod';

const expertModeTool = tool({
  name: 'enable_expert_mode',
  description: 'Enable expert mode for detailed technical responses',
  inputSchema: z.object({
    domain: z.string().describe('Technical domain (e.g., "kubernetes", "react")'),
  }),
  outputSchema: z.object({ enabled: z.boolean() }),

  nextTurnParams: {
    instructions: (params, context) => {
      const base = context.instructions ?? '';
      return `${base}

EXPERT MODE ENABLED for ${params.domain}:
- Provide detailed technical explanations
- Include code examples and best practices
- Reference official documentation
- Assume advanced knowledge`;
    },
    temperature: () => 0.3, // More precise for technical content
  },

  execute: async (params) => {
    return { enabled: true };
  },
});

The Claude Code Skills Pattern

This example shows how to recreate Claude Code’s skills system as a single encapsulated tool:
import { tool } from '@openrouter/agent';
import { readFileSync } from 'fs';
import { z } from 'zod';

const skillsTool = tool({
  name: "skill",
  description: `Load a specialized skill to enhance the assistant's capabilities.
Available skills: pdf-processing, data-analysis, code-review, etc.
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(),

  // nextTurnParams runs after all tool calls execute, before responses go to model
  // Executed in order of tools array. This is where the magic happens.
  nextTurnParams: {
    input: (params, context) => {
      // Prevent duplicate skill loading
      if (JSON.stringify(context.input).includes(`Skill ${params.type} is already loaded`)) {
        return context.input;
      }

      // Load the skill's instructions from file system
      const skill = readFileSync(
        `~/.claude/skills/${params.type}/SKILL.md`,
        "utf-8"
      );

      // Inject skill context into the conversation
      return [
        ...context.input,
        {
          role: "user",
          content: `Base directory for this skill: ~/.claude/skills/${params.type}/

${skill}`,
        },
      ];
    },
  },

  execute: async (params, context) => {
    // Check if already loaded
    if (JSON.stringify(context.input).includes(`Skill ${params.type} is already loaded`)) {
      return `Skill ${params.type} is already loaded`;
    }

    return `Launching skill ${params.type}`;
  },
});

// Usage - the skill automatically enriches future turns
const result = openrouter.callModel({
  model: 'anthropic/claude-sonnet-4.5',
  input: 'Process this PDF and extract the key findings',
  tools: [skillsTool],
});

Key Benefits

  1. Encapsulation: Skill loading logic is entirely contained in the tool
  2. Idempotency: Built-in check prevents loading the same skill twice
  3. Clean API: Callers don’t need to know about skill file locations
  4. Composability: Multiple skills can be loaded across turns

Execution Order

Understanding when nextTurnParams runs is crucial:
1. Model generates tool calls

2. All tool `execute` functions run

3. `nextTurnParams` functions run for each tool (in tools array order)

4. Modified parameters used for next model turn

5. Repeat until model stops calling tools

Available Context

nextTurnParams functions receive two arguments:

params

The validated input parameters that were passed to the tool:
nextTurnParams: {
  instructions: (params, context) => {
    // params is typed based on inputSchema
    console.log(params.type); // e.g., "pdf-processing"
    return `Handle ${params.type}`;
  },
},

context

The current request context, including:
PropertyTypeDescription
inputOpenResponsesInputCurrent message history
modelstring | undefinedCurrent model selection
modelsstring[] | undefinedModel fallback array
instructionsstring | undefinedCurrent system instructions
temperaturenumber | undefinedCurrent temperature
maxOutputTokensnumber | undefinedCurrent max tokens
topPnumber | undefinedCurrent top-p sampling
topKnumber | undefinedCurrent top-k sampling

Modifiable Parameters

You can modify CallModelInput parameters:
nextTurnParams: {
  // Modify message history
  input: (params, ctx) => [...ctx.input, newMessage],

  // Change model
  model: (params, ctx) => 'anthropic/claude-sonnet-4.5',

  // Update instructions
  instructions: (params, ctx) => `${ctx.instructions}\n\nNew context...`,

  // Adjust generation parameters
  temperature: (params, ctx) => 0.5,
  maxOutputTokens: (params, ctx) => 2000,
},

Patterns

Research Context Accumulation

Build up context as research progresses:
const researchTool = tool({
  name: "research",
  inputSchema: z.object({ topic: z.string() }),
  outputSchema: z.object({ findings: z.array(z.string()) }),

  nextTurnParams: {
    instructions: (params, context) => {
      const base = context.instructions ?? '';
      return `${base}

Previous research on "${params.topic}" found important context.
Build upon these findings in your response.`;
    },
  },

  execute: async (params) => {
    const results = await searchDatabase(params.topic);
    return { findings: results };
  },
});

Complexity-Based Model Selection

Upgrade to better models when needed:
const complexityAnalyzer = tool({
  name: "analyze_complexity",
  inputSchema: z.object({ code: z.string() }),
  outputSchema: z.object({ complexity: z.enum(['low', 'medium', 'high']) }),

  nextTurnParams: {
    model: (params, context) => {
      // Upgrade to more capable model for complex code
      if (params.complexity === 'high') {
        return 'anthropic/claude-sonnet-4.5';
      }
      return context.model ?? 'openai/gpt-5-nano';
    },
    temperature: (params, context) => {
      // Lower temperature for complex analysis
      return params.complexity === 'high' ? 0.3 : 0.7;
    },
  },

  execute: async (params) => {
    return analyzeCodeComplexity(params.code);
  },
});

Multi-Skill Loading

Load multiple skills at once:
const multiSkillLoader = tool({
  name: 'load_skills',
  description: 'Load multiple skills at once',
  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 = context.input;

      for (const skillName of params.skills) {
        const skillPath = `~/.skills/${skillName}/SKILL.md`;
        if (!existsSync(skillPath)) continue;

        const skillMarker = `[Skill: ${skillName}]`;
        if (JSON.stringify(newInput).includes(skillMarker)) continue;

        const skillContent = readFileSync(skillPath, 'utf-8');
        newInput = [
          ...(Array.isArray(newInput) ? newInput : [newInput]),
          { role: 'user', content: `${skillMarker}\n${skillContent}` },
        ];
      }

      return newInput;
    },
  },

  execute: async ({ skills }) => {
    const loaded = [];
    const failed = [];

    for (const skill of skills) {
      if (existsSync(`~/.skills/${skill}/SKILL.md`)) {
        loaded.push(skill);
      } else {
        failed.push({ name: skill, reason: 'Not found' });
      }
    }

    return { loaded, failed };
  },
});

Language/Locale Switching

Adapt to user language preferences:
const languageTool = tool({
  name: 'set_language',
  inputSchema: z.object({
    language: z.enum(['en', 'es', 'fr', 'de', 'ja']),
  }),
  outputSchema: z.object({ set: z.boolean() }),

  nextTurnParams: {
    instructions: (params, context) => {
      const base = context.instructions ?? '';
      const languageInstructions = {
        en: 'Respond in English.',
        es: 'Responde en español.',
        fr: 'Répondez en français.',
        de: 'Antworten Sie auf Deutsch.',
        ja: '日本語で回答してください。',
      };

      return `${base}\n\n${languageInstructions[params.language]}`;
    },
  },

  execute: async (params) => ({ set: true }),
});

Best Practices

Idempotency Checks

Always check if context was already added:
nextTurnParams: {
  input: (params, context) => {
    const marker = `[Context: ${params.id}]`;

    // Don't add if already present
    if (JSON.stringify(context.input).includes(marker)) {
      return context.input;
    }

    return [...context.input, {
      role: 'user',
      content: `${marker}\n${newContent}`,
    }];
  },
},

Type Safety

Use proper typing for context access:
nextTurnParams: {
  instructions: (params, context) => {
    // Safe access with fallback
    const base = context.instructions ?? 'You are a helpful assistant.';
    return `${base}\n\nAdditional context: ${params.data}`;
  },
},

Minimal Modifications

Only modify what’s necessary:
// Good: Minimal, targeted change
nextTurnParams: {
  temperature: (params) => params.needsPrecision ? 0.2 : undefined,
},

// Avoid: Unnecessary spreading
nextTurnParams: {
  temperature: (params, ctx) => {
    return params.needsPrecision ? 0.2 : ctx.temperature;
  },
},

See Also