HITL tools extend manual-tool semantics with two sync-or-async hooks that let you decide per call whether to respond programmatically or pause for a human:
onToolCalled — fires when the model invokes the tool. Return a value to feed the model directly (like a regular execute), or return null to pause the loop like a manual tool. The caller resumes later by supplying a function_call_output item.
onResponseReceived — optional. Fires on a later turn when an incoming function_call_output matches a prior call of this tool (by callId → function_call.name). It receives the caller-supplied raw result and returns the value sent to the model. Throwing surfaces as a tool error to the model.
An outputSchema is required for HITL tools — it validates both the onToolCalled return value (when non-null) and the value delivered via function_call_output (whether transformed by onResponseReceived or passed through directly).
const approvePaymentTool = tool({ name: 'approve_payment', description: 'Approve a payment, escalating large amounts to a human', inputSchema: z.object({ amount: z.number(), recipient: z.string(), }), outputSchema: z.object({ ok: z.boolean(), reviewedAt: z.number().optional(), }), onToolCalled: async (input) => { // Auto-approve small amounts if (input.amount < 100) { return { ok: true }; } // Escalate to a human — pauses the loop return null; }, onResponseReceived: async (raw) => { // Post-process the caller-supplied result before the model sees it return { ...(raw as object), reviewedAt: Date.now() }; },});
When onToolCalled returns null, the conversation state moves to status: 'awaiting_hitl' and the paused call surfaces via getToolCalls() / getPendingToolCalls(). Resume by calling callModel again with a function_call_output item for each paused call in the input.
HITL tools differ from requireApproval: approval gates pause before execution for a yes/no decision, while HITL tools let onToolCalled run arbitrary logic first and only pause when it returns null. Use HITL when the decision is data-driven (e.g., amount thresholds, risk scoring); use requireApproval when you always want explicit human consent. See Tool Approval & State.
const openrouter = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY });const result = openrouter.callModel({ model: 'openai/gpt-5-nano', input: 'What is the weather in Tokyo?', tools: [weatherTool],});// Tools are automatically executedconst text = await result.getText();// "The weather in Tokyo is 22°C and sunny."
Use as const for full type inference on tool calls:
const result = openrouter.callModel({ model: 'openai/gpt-5-nano', input: 'What is the weather?', tools: [weatherTool, searchTool] as const, maxToolRounds: 0, // Get tool calls without executing});// Tool calls are typed as union of tool inputsfor await (const toolCall of result.getToolCallsStream()) { if (toolCall.name === 'get_weather') { // toolCall.arguments is typed as { location: string } console.log('Weather for:', toolCall.arguments.location); }}
Tool execute functions receive a flat context object as
their second argument. It merges TurnContext fields
with a tools map and a setContext() method:
Use an async function for one-time initialization
that needs to fetch data:
const result = openrouter.callModel({ model: 'openai/gpt-5-nano', input: 'What is the weather?', tools: [weatherTool] as const, // Resolved once at turn 0 to seed the store context: async () => ({ get_weather: { apiKey: await fetchApiKey(), units: 'celsius', }, }),});
resolveContext runs once at turn 0 to seed the
context store. For per-turn mutations, use
setContext() inside your tool’s execute function.
Tools can update their own context using setContext().
Changes persist across turns via the shared store and
are visible immediately — context.local is a live
getter that always reads the latest values:
Use sharedSchema on tool() and sharedContextSchema
on callModel to share typed state across tools:
const SharedContextSchema = z.object({ _sessionId: z.string().optional(),});const execTool = tool({ name: 'sandbox_exec', inputSchema: z.object({ command: z.string() }), sharedSchema: SharedContextSchema, execute: async (input, ctx) => { // Read shared state set by any tool const sid = ctx.shared._sessionId; const session = await connect(sid); // Write shared state for other tools ctx.setSharedContext({ _sessionId: session.id }); return await session.exec(input.command); },});const result = openrouter.callModel({ model: 'openai/gpt-5-nano', input: 'Run a command', tools: [execTool] as const, sharedContextSchema: SharedContextSchema, context: { shared: { _sessionId: 'existing-session' }, sandbox_exec: {}, },});
context.local is scoped to one tool.
context.shared is visible to all tools and persists
across turns. Pass the same sharedSchema to each tool
for typed access, and sharedContextSchema to
callModel for runtime validation.
callModel automatically executes tools and handles multi-turn conversations. When the model calls a tool, the SDK executes it, sends the result back, and continues until the model provides a final response.
import { OpenRouter, tool } from '@openrouter/agent';import { z } from 'zod';const weatherTool = tool({ name: 'get_weather', inputSchema: z.object({ location: z.string() }), outputSchema: z.object({ temperature: z.number() }), execute: async ({ location }) => { return { temperature: await fetchTemperature(location) }; },});const result = openrouter.callModel({ model: 'openai/gpt-5-nano', input: 'What is the weather in Paris?', tools: [weatherTool],});// getText() waits for all tool execution to completeconst text = await result.getText();// "The weather in Paris is 18°C."
When using getFullResponsesStream(), you can also receive tool.result events that fire when a tool execution completes:
for await (const event of result.getFullResponsesStream()) { switch (event.type) { case 'tool.preliminary_result': // Intermediate progress from generator tools console.log(`Progress (${event.toolCallId}):`, event.result); break; case 'tool.result': // Final result when tool execution completes console.log(`Tool ${event.toolCallId} completed`); console.log('Result:', event.result); // Access any preliminary results that were emitted during execution if (event.preliminaryResults) { console.log('All progress events:', event.preliminaryResults); } break; }}
The tool.result event provides the final output from tool execution along with all intermediate preliminaryResults that were yielded during execution (for generator tools). This is useful when you need both real-time progress updates and a summary of all progress at completion.
When the model calls multiple tools, they execute in parallel:
const result = openrouter.callModel({ model: 'openai/gpt-5-nano', input: 'Get weather in Paris, Tokyo, and New York simultaneously', tools: [weatherTool],});// All three weather calls execute in parallelconst text = await result.getText();
const result = openrouter.callModel({ model: 'openai/gpt-5-nano', input: 'What is 2+2 and the weather in Paris?', tools: [calculatorTool, weatherTool],});const response = await result.getResponse();// Response includes all execution roundsconsole.log('Final output:', response.output);console.log('Usage:', response.usage);
// Good: Clear name and descriptionconst tool1 = tool({ name: 'search_knowledge_base', description: 'Search the company knowledge base for documents, FAQs, and policies. Returns relevant articles with snippets.', // ...});// Avoid: Vague or genericconst tool2 = tool({ name: 'search', description: 'Searches stuff', // ...});