
Company News
Socket Joins New OpenJS Program to Fund Node.js Security Work
Socket is joining the OpenJS Security Stewardship Program to fund Node.js vulnerability research, maintainer remediation, and security releases.
@openrouter/agent
Advanced tools
Agent toolkit for building AI applications with OpenRouter — tool orchestration, streaming, multi-turn conversations, and format compatibility.
Agent toolkit for building AI applications with OpenRouter — tool orchestration, streaming, multi-turn conversations, and format compatibility.
[!IMPORTANT] This SDK is currently in beta. There may be breaking changes between versions. We recommend pinning to a specific version in your
package.json.
# npm
npm install @openrouter/agent
# pnpm
pnpm add @openrouter/agent
# bun
bun add @openrouter/agent
# yarn
yarn add @openrouter/agent
[!NOTE] This package is ESM-only. If you are using CommonJS, you can use
await import('@openrouter/agent').
import OpenRouter from '@openrouter/sdk';
import { callModel, tool } from '@openrouter/agent';
import { z } from 'zod';
const client = new OpenRouter({ apiKey: 'YOUR_API_KEY' });
const weatherTool = tool({
name: 'get_weather',
description: 'Get the current weather for a location',
inputSchema: z.object({ location: z.string() }),
execute: async ({ location }) => ({
temperature: 72,
condition: 'sunny',
location,
}),
});
const result = callModel(client, {
model: 'openai/gpt-4o',
input: 'What is the weather in San Francisco?',
tools: [weatherTool] as const,
});
// Get the final text response (tools are auto-executed)
const text = await result.getText();
console.log(text);
callModel returns a ModelResult that supports many ways to consume the response — all usable concurrently on the same result:
const result = callModel(client, { model, input, tools });
// Await the final text
const text = await result.getText();
// Await the full response with usage data
const response = await result.getResponse();
console.log(response.usage); // { inputTokens, outputTokens, cost, ... }
// Stream text deltas
for await (const delta of result.getTextStream()) {
process.stdout.write(delta);
}
// Stream reasoning deltas
for await (const delta of result.getReasoningStream()) {
process.stdout.write(delta);
}
// Stream tool-call argument deltas (plus preliminary results from generator tools)
for await (const event of result.getToolStream()) {
console.log(event); // { type: 'delta', content: '...' } | { type: 'preliminary_result', ... }
}
// Stream all response events, including tool execution results
for await (const event of result.getFullResponsesStream()) {
// includes tool.result / tool.call_output events
}
// Stream structured tool calls
for await (const toolCall of result.getToolCallsStream()) {
console.log(toolCall.name, toolCall.input);
}
// Get all tool calls after completion
const toolCalls = await result.getToolCalls();
What each stream emits:
| Method | Emits |
|---|---|
getTextStream() | assistant text deltas |
getReasoningStream() | reasoning deltas |
getToolStream() | tool-call argument deltas; preliminary_result events for generator tools — not execution results |
getToolCallsStream() | parsed tool calls as they complete |
getItemsStream() | all output items (messages, function calls, …) |
getFullResponsesStream() | every response event, including tool.result / tool.call_output execution events |
The tool() factory creates type-safe tools with full Zod schema inference. Three tool types are supported:
Regular tools — automatically executed by the agent loop:
const searchTool = tool({
name: 'search',
description: 'Search the web',
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ results: z.array(z.string()) }),
execute: async ({ query }) => {
const results = await performSearch(query);
return { results };
},
});
Generator tools — stream intermediate events during execution:
const analysisTool = tool({
name: 'analyze',
inputSchema: z.object({ data: z.string() }),
eventSchema: z.object({ progress: z.number() }),
outputSchema: z.object({ summary: z.string() }),
execute: async function* ({ data }) {
yield { progress: 0.5 };
// ... processing ...
return { summary: 'Analysis complete' };
},
});
Manual tools — reported to the model but not auto-executed (for human-in-the-loop flows):
const confirmTool = tool({
name: 'confirm_action',
inputSchema: z.object({ action: z.string() }),
execute: false,
});
Control when the agent loop stops executing tools:
import { callModel, stepCountIs, hasToolCall, maxTokensUsed, maxCost } from '@openrouter/agent';
const result = callModel(client, {
model: 'openai/gpt-4o',
input: 'Research this topic thoroughly',
tools: [searchTool, summarizeTool] as const,
// Single condition
stopWhen: stepCountIs(10),
// Or combine multiple (stops when ANY condition is met)
stopWhen: [stepCountIs(10), maxCost(0.50), hasToolCall('summarize')],
});
Built-in stop conditions:
| Condition | Description |
|---|---|
stepCountIs(n) | Stop after n tool execution steps (default: 5) |
hasToolCall(name) | Stop when a specific tool is called |
maxTokensUsed(n) | Stop when total tokens exceed a threshold |
maxCost(dollars) | Stop when total cost exceeds a dollar amount |
finishReasonIs(reason) | Stop on a specific finish reason |
Final response after stop
When stopWhen fires while the model is still emitting tool calls, the
loop makes one more model turn with toolChoice: 'none' so the run ends
with a natural-language answer instead of a half-finished tool call.
Tools stay in the request — only calling is forbidden — which preserves
the prompt-cache prefix. This is on by default; allowFinalResponse
tunes it:
callModel(client, {
model: 'openai/gpt-4o',
input: 'Research this topic',
tools: [searchTool] as const,
stopWhen: stepCountIs(5),
// default (omitted or `true`): appends DEFAULT_FINAL_RESPONSE_DIRECTIVE
// as a final user message so the model writes an answer instead of
// attempting another tool call
// override the directive wording:
// allowFinalResponse: 'Please summarize what you found.',
// append no message (turn still happens, calls still forbidden):
// allowFinalResponse: '',
// disable the final turn entirely:
// allowFinalResponse: false,
});
The pending tool calls from the halted turn are executed first so they
have real outputs in the input, then the full conversation and the
original instructions are sent with toolChoice: 'none'. Any
non-executable (manual) tool calls in the halted turn are paired with
synthesized stub function_call_output items so the input is well-formed.
Gate tool execution with approval checks for sensitive operations:
const deleteTool = tool({
name: 'delete_record',
inputSchema: z.object({ id: z.string() }),
requireApproval: true, // Always require approval
execute: async ({ id }) => { /* ... */ },
});
// Or use a function for conditional approval
const writeTool = tool({
name: 'write_file',
inputSchema: z.object({ path: z.string(), content: z.string() }),
requireApproval: ({ path }) => path.startsWith('/etc'),
execute: async ({ path, content }) => { /* ... */ },
});
// Handle approvals at the callModel level
const result = callModel(client, {
model: 'openai/gpt-4o',
input: 'Delete record abc-123',
tools: [deleteTool] as const,
approveToolCalls: async (toolCalls) => {
// Return IDs of approved tool calls
return toolCalls.map(tc => tc.id);
},
});
Observe and control the agent loop with typed lifecycle hooks — inspect or block tool calls, mutate inputs, gate approvals programmatically, intercept prompts, and run audit/telemetry work. Inspired by the Claude Agent SDK hooks pattern.
[!NOTE] Lifecycle hooks are distinct from the SDK transport hooks (
SDKHooks,BeforeRequestHook,HookContext, …), which intercept HTTP requests. Lifecycle hooks fire on agent-loop events.
Two usage modes. Pass a plain object for quick setup, or a HooksManager
instance for custom hooks, dynamic registration, and programmatic emit:
// Inline config — built-in hooks only
const result = callModel(client, {
model: 'openai/gpt-4o',
input: 'Clean up the temp directory',
tools: [shellTool] as const,
hooks: {
PreToolUse: [
{
matcher: 'run_shell', // string | RegExp | (toolName) => boolean
handler: ({ toolName, toolInput }) => {
if (String(toolInput.command).includes('rm -rf /')) {
return { block: 'Refusing to run a destructive command' };
}
},
},
],
PostToolUse: [
{ handler: ({ toolName, durationMs }) => console.log(toolName, durationMs) },
],
},
});
// HooksManager — full control
import { HooksManager } from '@openrouter/agent';
const hooks = new HooksManager();
const unsubscribe = hooks.on('PreToolUse', {
matcher: /^db_/,
filter: (payload) => Object.keys(payload.toolInput).length > 0, // optional predicate on the payload
handler: ({ toolInput }, ctx) => {
console.log(`[${ctx.sessionId}] intercepting db tool`); // session id lives on the context
return {
// Replace the tool's input before execution (mutation piping)
mutatedInput: { ...toolInput, dryRun: true },
};
},
});
const result = callModel(client, { model, input, tools, hooks });
// Later: unsubscribe(), hooks.off(...), hooks.removeAll(...)
Built-in hooks
| Hook | Fires | Result fields |
|---|---|---|
PreToolUse | Before every client-tool execution (auto, approval-resume, and hook-allowed paths) | mutatedInput replaces the tool's arguments; block: true | string skips execution and reports the reason as the tool's error output |
PostToolUse | After a successful tool execution (payload includes toolOutput, durationMs) | none (void) |
PostToolUseFailure | After a tool execution throws or returns an error. Not fired when a tool never ran (PermissionRequest deny, user rejection, PreToolUse block) — observe those via the gating hooks themselves | none (void) |
UserPromptSubmit | Before the initial API request, with the user prompt string | mutatedPrompt replaces the prompt; reject: true | string aborts the call with an error |
PermissionRequest | When a tool requires approval, before pausing for the human gate | decision: 'allow' skips the gate (the tool runs once via the normal round), 'deny' synthesizes a rejected result without executing, 'ask_user' (default) falls through to the approval flow. Last handler wins. Payload includes a riskLevel derived from the approval gate's shape ('high' for tool- or call-level functions, 'medium' for blanket true) |
Stop | When a stopWhen condition halts the loop (reason: 'max_turns') | forceResume: true continues the loop (capped at 3 consecutive overrides without tool progress — a bare forceResume that changes no state will typically re-trigger the stop condition immediately and burn through the cap, so pair it with appendPrompt or external state the stop condition observes); appendPrompt injects a user message for the next turn (honored independently of forceResume). Blocked/rejected tool outputs count as progress for the cap: the model receives that feedback, and each round costs a full request, so the loop cannot spin hot |
SessionStart | Once per run, before the initial request. config summarizes the session (hasTools, hasApproval, hasState) | none (void) |
SessionEnd | Once per run, on every exit path — completion, approval pause, interruption, error, and the no-tools streaming paths. reason is 'complete' | 'error' | 'max_turns' | 'user'. When at least one model call completed, totalUsage aggregates tokens/cost across all of them (modelCalls, inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens, and cost when the server reported it) | none (void) |
PostModelCall | Once per completed model response, on every request the loop makes — initial, each tool-round follow-up, the empty-final retry, the allowFinalResponse final turn, and approval-resume requests. Payload: responseId (the OpenRouter generation id), model, durationMs (dispatch → fully materialized response, including stream consumption), turnType ('initial' | 'resume' | 'tool_round' | 'final' | 'retry'), turnNumber, and usage (inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens, cost?) when the server reported usage accounting. Purely observational — the telemetry primitive for tracing/benchmark consumers: one span per model call | none (void) |
Notes on lifecycle pairing: SessionEnd only fires when a matching
SessionStart succeeded, and at most once per run. Pending async hook work is
always drained on teardown — including on paths that skip SessionStart,
such as resuming from a tool approval. A throwing SessionEnd handler never
masks the run's original error (teardown failures are logged as warnings).
On no-tools streaming paths the initial response is only materialized when the
stream is consumed, so PostModelCall for that response fires during session
teardown (before SessionEnd). A stream that fails or errors before producing
a materialized response emits no PostModelCall; a response.incomplete
response (e.g. truncated at max_output_tokens) does emit — it carries a
real generation id and consumed tokens. Note usage.cost is only present when
the request had usage accounting enabled server-side.
Every handler receives (payload, context) — context carries the
sessionId (the single source of session identity; payloads do not repeat
it), the hookName, and an AbortSignal for cooperative cancellation. The
engine threads the session id into each emit's context, so a single
HooksManager instance can be shared safely across concurrent callModel
runs — each run's handlers see that run's id. (If you call emit() yourself
on a shared manager, pass { sessionId } in the emit context; the
setSessionId() default is a single mutable field and is last-writer-wins.)
Handler chain semantics
Handlers for a hook run sequentially in registration order.
matcher) scope a handler to tool names — exact string,
RegExp (stateful /g///y flags are handled safely), or a predicate
function (truthy/falsy returns are coerced to boolean). Matchers fail
closed: a matcher-scoped handler is skipped when the emit has no tool name.filter) are arbitrary predicates on the payload.mutatedInput/mutatedPrompt replaces the
corresponding payload field for all subsequent handlers in the chain, and
for the tool/request itself. A blocking handler's mutation still lands
before the short-circuit.block/reject with true or a non-empty string stops
the chain (empty strings do not block).new HooksManager(custom, { throwOnHandlerError: true }) to propagate
errors instead (useful in tests).Async fire-and-forget handlers
A handler can detach background work (telemetry, audit writes) without
blocking the loop by returning an AsyncOutput signal:
hooks.on('PostToolUse', {
handler: (payload, ctx) => ({
async: true,
work: sendTelemetry(payload, { signal: ctx.signal }),
asyncTimeout: 5_000, // default 30_000
}),
});
// On shutdown: abort in-flight handlers, then wait for detached work
hooks.abortInflight('shutdown');
await hooks.drain();
drain() waits for all detached work, bounded per-handler by asyncTimeout.
On timeout the emit's ctx.signal is aborted and a warning is logged — the
work itself cannot be forcibly cancelled, so handlers should observe
ctx.signal to stop cooperatively. abortInflight() reaches detached work
even after the originating emit() has returned. The signal object must have
exactly the AsyncOutput shape (async: true plus optional work/
asyncTimeout); a return value carrying any other field is treated as a
regular result so mutations/blocks are never silently discarded. The
isAsyncOutput type guard is exported.
Custom hooks
Define your own hooks with Zod schema pairs and full type inference, then emit them from your own code:
import { HooksManager } from '@openrouter/agent';
import { z } from 'zod/v4';
const hooks = new HooksManager({
DeploymentGate: {
payload: z.object({ environment: z.string(), version: z.string() }),
result: z.object({ approved: z.boolean() }),
},
AuditLog: {
payload: z.object({ event: z.string() }),
result: z.void(), // side-effect only: results are not validated
},
});
hooks.on('DeploymentGate', {
handler: ({ environment }) => ({ approved: environment !== 'production' }),
});
const { results } = await hooks.emit('DeploymentGate', {
environment: 'staging',
version: '1.2.3',
});
Payloads and results are validated against the schemas on every emit.
Schemas with .transform(), .default(), or .coerce are honored: handlers
receive the parsed output values, matching the inferred TypeScript types.
Custom hook names must be non-empty and must not collide with built-in names.
Custom hooks do not participate in mutation piping or blocking (those are
built-in-only behaviors); the inline config surface only accepts built-in
hooks — unknown names are warned about and skipped.
A payload validation failure follows the same error policy as handlers: logged and skipped by default, thrown in strict mode.
Provide typed context data to tools without passing it through the model:
const dbTool = tool({
name: 'query_db',
inputSchema: z.object({ sql: z.string() }),
contextSchema: z.object({ connectionString: z.string() }),
execute: async ({ sql }, ctx) => {
// Tool context is available on ctx.local
const db = connect(ctx?.local.connectionString);
return db.query(sql);
},
});
const result = callModel(client, {
model: 'openai/gpt-4o',
input: 'List all users',
tools: [dbTool] as const,
context: {
query_db: { connectionString: 'postgres://localhost/mydb' },
},
});
Share mutable state across all tools in a conversation:
const result = callModel(client, {
model: 'openai/gpt-4o',
input: 'Process these items',
tools: [toolA, toolB] as const,
sharedContextSchema: z.object({ processedIds: z.array(z.string()) }),
context: {
shared: { processedIds: [] },
},
});
Persist multi-turn conversations with full state tracking. The state
option takes a StateAccessor — a { load, save } pair over any storage
backend (memory, SQLite, Redis, …). The loop calls load before the run and
save as the conversation progresses:
import { callModel, type ConversationState, type StateAccessor } from '@openrouter/agent';
// Any storage backend — here, a simple in-memory holder
let stored: ConversationState | null = null;
const state: StateAccessor = {
load: async () => stored,
save: async (s) => {
stored = s;
},
};
// First turn
const result1 = callModel(client, {
model: 'openai/gpt-4o',
input: 'Search for TypeScript best practices',
tools: [searchTool] as const,
state,
});
await result1.getText();
// Read the updated state (messages, tool calls, status, metadata)
const snapshot = await result1.getState();
// Continue the conversation — the accessor loads the saved history
const result2 = callModel(client, {
model: 'openai/gpt-4o',
input: 'Now summarize what you found',
tools: [searchTool] as const,
state,
});
ConversationState is plain JSON — JSON.stringify/JSON.parse it into any
store (this is how serverless/cold-start resume works).
Adjust model parameters dynamically based on tool execution:
const searchTool = tool({
name: 'search',
inputSchema: z.object({ query: z.string() }),
nextTurnParams: {
temperature: (input) => input.query.includes('creative') ? 0.9 : 0.1,
maxOutputTokens: () => 2000,
},
execute: async ({ query }) => { /* ... */ },
});
Convert between OpenRouter and other message formats:
import { toClaudeMessage, fromClaudeMessages } from '@openrouter/agent';
import { toChatMessage, fromChatMessages } from '@openrouter/agent';
// Anthropic Claude format
const claudeMsg = toClaudeMessage(openRouterMessage);
const orMessages = fromClaudeMessages(claudeMessages);
// Standard Chat format
const chatMsg = toChatMessage(openRouterMessage);
const orMessages2 = fromChatMessages(chatMessages);
For tree-shaking or targeted imports, the package provides granular subpath exports:
import { callModel } from '@openrouter/agent/call-model';
import { tool } from '@openrouter/agent/tool';
import { ModelResult } from '@openrouter/agent/model-result';
import { HooksManager } from '@openrouter/agent/hooks-manager';
import { stepCountIs, maxCost } from '@openrouter/agent/stop-conditions';
import { toClaudeMessage } from '@openrouter/agent/anthropic-compat';
import { toChatMessage } from '@openrouter/agent/chat-compat';
import { ToolContextStore } from '@openrouter/agent/tool-context';
import { ToolEventBroadcaster } from '@openrouter/agent/tool-event-broadcaster';
import { createInitialState } from '@openrouter/agent/conversation-state';
# Install dependencies
pnpm install
# Build
pnpm build
# Run unit tests
pnpm test
# Run end-to-end tests (requires OPENROUTER_API_KEY in .env)
pnpm test:e2e
# Type check
pnpm typecheck
# Lint
pnpm lint
Create a .env file with your OpenRouter API key:
OPENROUTER_API_KEY=sk-or-...
Then run:
pnpm test # Unit tests
pnpm test:e2e # Integration tests (requires API key)
Full callModel documentation is available at openrouter.ai/docs/sdks/typescript/call-model.
Apache-2.0
FAQs
Agent toolkit for building AI applications with OpenRouter — tool orchestration, streaming, multi-turn conversations, and format compatibility.
The npm package @openrouter/agent receives a total of 50,193 weekly downloads. As such, @openrouter/agent popularity was classified as popular.
We found that @openrouter/agent demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 9 open source maintainers collaborating on the project.

Company News
Socket is joining the OpenJS Security Stewardship Program to fund Node.js vulnerability research, maintainer remediation, and security releases.

Security News
Two compromised GitHub Actions were re-enabled with malicious tags intact, exposing thousands of downstream repositories to Mini Shai-Hulud.

Research
/Security News
A malicious Firefox extension fetches its payload after installation to evade detection, steal Google session cookies, and automate account takeover.