From c8dd90965114db359fd15c99cc22c61a1e3d74ee Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 24 Mar 2026 09:12:16 -0400 Subject: [PATCH 01/10] feat(openrouter-typescript-sdk): add Approval Flows with Manual Tools section Document the critical behavior that when a manual tool (execute: false) is called during a multi-turn callModel invocation, the SDK stops the loop and returns with the model's tool-call response in finalResponse.output. Includes patterns for approval flows, streaming detection, resuming after approval, and mixing manual with automatic tools. --- skills/openrouter-typescript-sdk/SKILL.md | 182 +++++++++++++++++++++- 1 file changed, 181 insertions(+), 1 deletion(-) diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index 1389155..7002c81 100644 --- a/skills/openrouter-typescript-sdk/SKILL.md +++ b/skills/openrouter-typescript-sdk/SKILL.md @@ -474,7 +474,7 @@ const searchTool = tool({ ``` #### Manual Tools -Set `execute: false` to handle tool calls yourself: +Set `execute: false` to prevent the SDK from automatically executing the tool. When a manual tool is called during a multi-turn `callModel` invocation, the SDK stops the automatic tool-execution loop, populates `finalResponse`, and returns. The model's response that requested the manual tool call is available in `finalResponse.output`. See [Approval Flows with Manual Tools](#approval-flows-with-manual-tools) for the full pattern. ```typescript const manualTool = tool({ @@ -531,6 +531,186 @@ const result = client.callModel({ --- +## Approval Flows with Manual Tools + +When a manual tool (`execute: false`) is called during a multi-turn `callModel` invocation, the SDK **stops the tool-execution loop immediately** and returns. The key behavior: + +1. The loop does **not** execute the tool (there is no `execute` function) +2. The loop sets `finalResponse` and returns control to the caller +3. The model's response that requested the manual tool call **is included in `finalResponse.output`** — this is how to extract the tool call arguments + +This makes manual tools the foundation for approval flows, human-in-the-loop patterns, and any scenario where external action must be confirmed before proceeding. + +### Approval Flow Pattern + +```typescript +import OpenRouter, { tool } from '@openrouter/sdk'; +import { z } from 'zod'; + +const client = new OpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY +}); + +// Step 1: Define a manual tool for the action requiring approval +const sendEmailTool = tool({ + name: 'send_email', + description: 'Send an email to a recipient', + inputSchema: z.object({ + to: z.string().describe('Recipient email address'), + subject: z.string().describe('Email subject'), + body: z.string().describe('Email body') + }), + execute: false // Manual — SDK will stop and return when this is called +}); + +// Step 2: Call the model with the manual tool +const result = client.callModel({ + model: 'openai/gpt-5-nano', + instructions: 'You are an email assistant. Use the send_email tool when the user asks to send an email.', + input: 'Send an email to alice@example.com about the meeting tomorrow at 3pm', + tools: [sendEmailTool] +}); + +// Step 3: Get the final response — the loop stopped at the manual tool call +const response = await result.getResponse(); + +// Step 4: Find the manual tool call in the response output +const toolCall = response.output.find( + (item) => item.type === 'function_call' && item.name === 'send_email' +); + +if (toolCall) { + const args = JSON.parse(toolCall.arguments); + + // Step 5: Present to user for approval + console.log('The assistant wants to send an email:'); + console.log(` To: ${args.to}`); + console.log(` Subject: ${args.subject}`); + console.log(` Body: ${args.body}`); + + const approved = await promptUser('Approve? (y/n)'); + + if (approved) { + // Step 6: Execute the action manually + await sendEmail(args.to, args.subject, args.body); + console.log('Email sent.'); + } else { + console.log('Email cancelled.'); + } +} +``` + +### Streaming with Manual Tool Detection + +For streaming scenarios, use `getFullResponsesStream()` to detect the manual tool call as it arrives: + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Send a message to the team about the deploy', + tools: [sendEmailTool, searchTool] // Mix of manual and automatic tools +}); + +let manualToolArgs = ''; +let manualToolDetected = false; + +for await (const event of result.getFullResponsesStream()) { + switch (event.type) { + case 'response.output_text.delta': + process.stdout.write(event.delta); + break; + + case 'response.function_call_arguments.delta': + manualToolArgs += event.delta; + break; + + case 'response.function_call_arguments.done': + // Check the completed response to identify if this is the manual tool + manualToolDetected = true; + break; + + case 'response.completed': + // The response is complete — if the manual tool was called, + // the loop has stopped and we can inspect the output + if (manualToolDetected) { + const call = event.response.output.find( + (item) => item.type === 'function_call' && item.name === 'send_email' + ); + if (call) { + const args = JSON.parse(call.arguments); + // Present to user for approval... + } + } + break; + } +} +``` + +### Resuming After Approval + +To continue the conversation after manually handling the tool call, pass the tool result back as input for the next `callModel` invocation: + +```typescript +// After the user approves and the action is executed: +const toolResult = { status: 'sent', messageId: '12345' }; + +const followUp = client.callModel({ + model: 'openai/gpt-5-nano', + input: [ + // Include the prior conversation history + ...previousMessages, + // Include the assistant's tool call from finalResponse.output + ...response.output, + // Add the tool result + { + type: 'function_call_output', + call_id: toolCall.call_id, + output: JSON.stringify(toolResult) + } + ], + tools: [sendEmailTool] +}); + +const text = await followUp.getText(); +console.log(text); // e.g. "Email sent successfully to alice@example.com!" +``` + +### Mixing Manual and Automatic Tools + +When both manual and automatic tools are provided, the SDK executes automatic tools normally through the multi-turn loop. The loop only stops when the model calls a manual tool: + +```typescript +const searchTool = tool({ + name: 'search', + description: 'Search for information', + inputSchema: z.object({ query: z.string() }), + execute: async ({ query }) => { + return { results: ['Result 1', 'Result 2'] }; + } +}); + +const deleteTool = tool({ + name: 'delete_record', + description: 'Delete a database record', + inputSchema: z.object({ + recordId: z.string().describe('ID of the record to delete') + }), + execute: false // Requires approval +}); + +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Find the outdated records and delete them', + tools: [searchTool, deleteTool] +}); + +// The SDK will automatically execute search calls in the loop. +// When the model calls delete_record, the loop stops and returns. +const response = await result.getResponse(); +``` + +--- + ## Dynamic Parameters Compute parameters based on conversation context: From ee30fb95b3ff5be9c12e98b3c3456b6290f7fdb5 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 24 Mar 2026 12:23:49 -0400 Subject: [PATCH 02/10] fix(openrouter-typescript-sdk): separate manual tools from approval flows The previous commit incorrectly conflated manual tools with approval flows. These are two distinct SDK features: - Manual tools (execute: false): loop exits, developer handles execution externally, passes results back via function_call_output on next call - Approval flows (requireApproval): built-in SDK feature where the loop pauses to 'awaiting_approval' status, developer provides approveToolCalls or rejectToolCalls on next call, state persisted via StateAccessor Rewrites the Manual Tools subsection, replaces the combined section with separate "Manual Tool Execution" and "Approval Flows" sections covering the full lifecycle of each. --- skills/openrouter-typescript-sdk/SKILL.md | 290 ++++++++++++++-------- 1 file changed, 180 insertions(+), 110 deletions(-) diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index 7002c81..a9698af 100644 --- a/skills/openrouter-typescript-sdk/SKILL.md +++ b/skills/openrouter-typescript-sdk/SKILL.md @@ -474,7 +474,7 @@ const searchTool = tool({ ``` #### Manual Tools -Set `execute: false` to prevent the SDK from automatically executing the tool. When a manual tool is called during a multi-turn `callModel` invocation, the SDK stops the automatic tool-execution loop, populates `finalResponse`, and returns. The model's response that requested the manual tool call is available in `finalResponse.output`. See [Approval Flows with Manual Tools](#approval-flows-with-manual-tools) for the full pattern. +Set `execute: false` to handle tool execution entirely outside the SDK. When the model calls a manual tool during the `callModel` loop, the loop exits early and returns. The model's response (including the tool call) is available in the response output. Retrieve the tool calls, execute them externally, and pass results back as `function_call_output` messages on the next `callModel` call. See [Manual Tool Execution](#manual-tool-execution) for the full lifecycle. ```typescript const manualTool = tool({ @@ -531,17 +531,11 @@ const result = client.callModel({ --- -## Approval Flows with Manual Tools +## Manual Tool Execution -When a manual tool (`execute: false`) is called during a multi-turn `callModel` invocation, the SDK **stops the tool-execution loop immediately** and returns. The key behavior: +When the model calls a manual tool (`execute: false`) during the `callModel` loop, the loop exits early and returns. The model's response — including the tool call — is in the response output. The developer is responsible for executing the tool externally and passing results back on the next `callModel` invocation. -1. The loop does **not** execute the tool (there is no `execute` function) -2. The loop sets `finalResponse` and returns control to the caller -3. The model's response that requested the manual tool call **is included in `finalResponse.output`** — this is how to extract the tool call arguments - -This makes manual tools the foundation for approval flows, human-in-the-loop patterns, and any scenario where external action must be confirmed before proceeding. - -### Approval Flow Pattern +### Manual Tool Lifecycle ```typescript import OpenRouter, { tool } from '@openrouter/sdk'; @@ -551,162 +545,238 @@ const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }); -// Step 1: Define a manual tool for the action requiring approval -const sendEmailTool = tool({ - name: 'send_email', - description: 'Send an email to a recipient', +// 1. Define a manual tool +const runQueryTool = tool({ + name: 'run_query', + description: 'Execute a database query', inputSchema: z.object({ - to: z.string().describe('Recipient email address'), - subject: z.string().describe('Email subject'), - body: z.string().describe('Email body') + sql: z.string().describe('The SQL query to execute') }), - execute: false // Manual — SDK will stop and return when this is called + execute: false // SDK will not execute this — loop exits when called }); -// Step 2: Call the model with the manual tool +// 2. Call the model — loop exits when the manual tool is called const result = client.callModel({ model: 'openai/gpt-5-nano', - instructions: 'You are an email assistant. Use the send_email tool when the user asks to send an email.', - input: 'Send an email to alice@example.com about the meeting tomorrow at 3pm', - tools: [sendEmailTool] + input: 'How many users signed up last week?', + tools: [runQueryTool] }); -// Step 3: Get the final response — the loop stopped at the manual tool call const response = await result.getResponse(); -// Step 4: Find the manual tool call in the response output +// 3. Extract the tool call from response output const toolCall = response.output.find( - (item) => item.type === 'function_call' && item.name === 'send_email' + (item) => item.type === 'function_call' && item.name === 'run_query' ); if (toolCall) { const args = JSON.parse(toolCall.arguments); - // Step 5: Present to user for approval - console.log('The assistant wants to send an email:'); - console.log(` To: ${args.to}`); - console.log(` Subject: ${args.subject}`); - console.log(` Body: ${args.body}`); + // 4. Execute the tool externally + const queryResult = await db.query(args.sql); - const approved = await promptUser('Approve? (y/n)'); + // 5. Pass the result back on the next callModel call + const followUp = client.callModel({ + model: 'openai/gpt-5-nano', + input: [ + ...previousMessages, + ...response.output, + { + type: 'function_call_output', + call_id: toolCall.call_id, + output: JSON.stringify(queryResult) + } + ], + tools: [runQueryTool] + }); - if (approved) { - // Step 6: Execute the action manually - await sendEmail(args.to, args.subject, args.body); - console.log('Email sent.'); - } else { - console.log('Email cancelled.'); - } + const text = await followUp.getText(); + console.log(text); } ``` -### Streaming with Manual Tool Detection +### Mixing Manual and Automatic Tools -For streaming scenarios, use `getFullResponsesStream()` to detect the manual tool call as it arrives: +When both manual and automatic tools are provided, automatic tools execute normally through the multi-turn loop. The loop only exits when the model calls a manual tool: ```typescript +const searchTool = tool({ + name: 'search', + description: 'Search for information', + inputSchema: z.object({ query: z.string() }), + execute: async ({ query }) => { + return { results: ['Result 1', 'Result 2'] }; + } +}); + +const deployTool = tool({ + name: 'deploy', + description: 'Deploy to production', + inputSchema: z.object({ + service: z.string().describe('Service to deploy'), + version: z.string().describe('Version tag') + }), + execute: false // Developer handles this externally +}); + const result = client.callModel({ model: 'openai/gpt-5-nano', - input: 'Send a message to the team about the deploy', - tools: [sendEmailTool, searchTool] // Mix of manual and automatic tools + input: 'Find the latest stable version of the auth service and deploy it', + tools: [searchTool, deployTool] }); -let manualToolArgs = ''; -let manualToolDetected = false; +// The SDK automatically executes search calls in the loop. +// When the model calls deploy, the loop exits and returns. +const response = await result.getResponse(); +``` -for await (const event of result.getFullResponsesStream()) { - switch (event.type) { - case 'response.output_text.delta': - process.stdout.write(event.delta); - break; +--- - case 'response.function_call_arguments.delta': - manualToolArgs += event.delta; - break; +## Approval Flows - case 'response.function_call_arguments.done': - // Check the completed response to identify if this is the manual tool - manualToolDetected = true; - break; +The SDK provides a built-in approval system for tools that should execute automatically *once approved*, but pause the loop when approval has not yet been granted. Unlike manual tools (where the developer always handles execution externally), approval-gated tools have an `execute` function — the SDK just won't call it until the tool call is approved. - case 'response.completed': - // The response is complete — if the manual tool was called, - // the loop has stopped and we can inspect the output - if (manualToolDetected) { - const call = event.response.output.find( - (item) => item.type === 'function_call' && item.name === 'send_email' - ); - if (call) { - const args = JSON.parse(call.arguments); - // Present to user for approval... - } - } - break; - } -} -``` +Approval state is persisted across `callModel` invocations via a `state` accessor, so the developer can inspect pending tool calls, present them to the user, and provide approval or rejection decisions on the next call. -### Resuming After Approval +### Defining Approval-Gated Tools -To continue the conversation after manually handling the tool call, pass the tool result back as input for the next `callModel` invocation: +Set `requireApproval` on the tool definition — either a static boolean or a dynamic check function: ```typescript -// After the user approves and the action is executed: -const toolResult = { status: 'sent', messageId: '12345' }; +import OpenRouter, { tool } from '@openrouter/sdk'; +import { z } from 'zod'; -const followUp = client.callModel({ - model: 'openai/gpt-5-nano', - input: [ - // Include the prior conversation history - ...previousMessages, - // Include the assistant's tool call from finalResponse.output - ...response.output, - // Add the tool result - { - type: 'function_call_output', - call_id: toolCall.call_id, - output: JSON.stringify(toolResult) - } - ], - tools: [sendEmailTool] +// Static: always requires approval +const deleteTool = tool({ + name: 'delete_record', + description: 'Delete a database record', + inputSchema: z.object({ + recordId: z.string().describe('ID of the record to delete') + }), + requireApproval: true, + execute: async ({ recordId }) => { + await db.delete(recordId); + return { deleted: recordId }; + } }); -const text = await followUp.getText(); -console.log(text); // e.g. "Email sent successfully to alice@example.com!" +// Dynamic: conditionally requires approval based on arguments +const transferTool = tool({ + name: 'transfer_funds', + description: 'Transfer funds between accounts', + inputSchema: z.object({ + from: z.string(), + to: z.string(), + amount: z.number() + }), + requireApproval: async (params, context) => { + // Only require approval for large transfers + return params.amount > 1000; + }, + execute: async ({ from, to, amount }) => { + return await ledger.transfer(from, to, amount); + } +}); ``` -### Mixing Manual and Automatic Tools +### State Management -When both manual and automatic tools are provided, the SDK executes automatic tools normally through the multi-turn loop. The loop only stops when the model calls a manual tool: +Approval flows require a `state` accessor to persist conversation state (including pending tool calls) across `callModel` invocations. The `state` parameter uses a `StateAccessor` interface: ```typescript -const searchTool = tool({ - name: 'search', - description: 'Search for information', - inputSchema: z.object({ query: z.string() }), - execute: async ({ query }) => { - return { results: ['Result 1', 'Result 2'] }; - } +// In-memory state storage (for simple cases) +let savedState = null; + +const stateAccessor = { + get: async () => savedState, + set: async (state) => { savedState = state; } +}; +``` + +The `ConversationState` object managed by the SDK contains: + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | Unique conversation ID | +| `messages` | `Message[]` | Full message history | +| `status` | `string` | `'in_progress'` \| `'awaiting_approval'` \| `'complete'` \| `'interrupted'` | +| `pendingToolCalls` | `ParsedToolCall[]` | Tool calls awaiting approval | +| `unsentToolResults` | `ToolExecutionResult[]` | Results not yet sent to API | +| `previousResponseId` | `string` | Last response ID | +| `updatedAt` | `Date` | Auto-updated timestamp | + +### callModel-Level Approval Parameters + +| Parameter | Description | +|-----------|-------------| +| `state` | `StateAccessor` (`get`/`set`) — required for approval/rejection params | +| `requireApproval` | Callback `(toolCall, context) => boolean` checked for each tool call (overrides tool-level setting) | +| `approveToolCalls` | Array of tool call IDs to approve and execute | +| `rejectToolCalls` | Array of tool call IDs to reject | + +**Note:** `approveToolCalls` and `rejectToolCalls` require a `state` accessor — TypeScript will emit a compilation error if state is omitted. + +### Complete Approval Flow Example + +```typescript +const client = new OpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY }); +let savedState = null; +const stateAccessor = { + get: async () => savedState, + set: async (state) => { savedState = state; } +}; + const deleteTool = tool({ - name: 'delete_record', - description: 'Delete a database record', + name: 'delete_user', + description: 'Delete a user account', inputSchema: z.object({ - recordId: z.string().describe('ID of the record to delete') + userId: z.string().describe('User ID to delete') }), - execute: false // Requires approval + requireApproval: true, + execute: async ({ userId }) => { + await db.deleteUser(userId); + return { deleted: userId }; + } }); +// First call — the loop pauses when delete_user is called const result = client.callModel({ model: 'openai/gpt-5-nano', - input: 'Find the outdated records and delete them', - tools: [searchTool, deleteTool] + input: 'Delete the account for user u_abc123', + tools: [deleteTool], + state: stateAccessor }); -// The SDK will automatically execute search calls in the loop. -// When the model calls delete_record, the loop stops and returns. const response = await result.getResponse(); +// response status is 'awaiting_approval' +// savedState.pendingToolCalls contains the delete_user call + +// Inspect pending calls and present to user +const pending = savedState.pendingToolCalls; +for (const call of pending) { + console.log(`Tool: ${call.name}, Args: ${JSON.stringify(call.arguments)}`); +} + +// User approves — pass approval decisions on next callModel call +const approved = await promptUser('Approve deletion? (y/n)'); + +const followUp = client.callModel({ + model: 'openai/gpt-5-nano', + input: [], // No new user input — just processing approvals + tools: [deleteTool], + state: stateAccessor, + ...(approved + ? { approveToolCalls: pending.map(c => c.id) } + : { rejectToolCalls: pending.map(c => c.id) } + ) +}); + +// If approved, the SDK executes delete_user and continues the loop +const text = await followUp.getText(); +console.log(text); ``` --- From b3701ce925de06314b3bfc945725334f0955faf7 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 24 Mar 2026 12:48:19 -0400 Subject: [PATCH 03/10] feat(openrouter-typescript-sdk): document getItemsStream() cumulative emission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add section explaining that function_call items in getItemsStream() are emitted cumulatively — the same tool call is yielded multiple times with progressively longer arguments. JSON.parse always succeeds because callModel heals partial JSON in arguments and structured results. --- skills/openrouter-typescript-sdk/SKILL.md | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index a9698af..a825304 100644 --- a/skills/openrouter-typescript-sdk/SKILL.md +++ b/skills/openrouter-typescript-sdk/SKILL.md @@ -347,6 +347,7 @@ The result object provides multiple methods for consuming the response: | `getTextStream()` | Stream text deltas as they arrive | | `getReasoningStream()` | Stream reasoning tokens (for o1/reasoning models) | | `getToolCallsStream()` | Stream tool calls as they complete | +| `getItemsStream()` | Stream response items cumulatively as they build up | ### getText() @@ -1284,6 +1285,40 @@ for await (const message of result.getNewMessagesStream()) { } ``` +### Items Stream (Cumulative Emission) + +The `getItemsStream()` method yields response items as they build up. Unlike delta-based streams, `function_call` items are **emitted cumulatively** — the same tool call is yielded multiple times with progressively longer `arguments` as chunks arrive from the API. Each emission represents the current accumulated state of that item. + +`JSON.parse` on `arguments` and structured results will always succeed, even on partial emissions. The SDK heals incomplete JSON from the stream so that each cumulative snapshot is valid and parseable. + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Look up the weather in Paris and Tokyo', + tools: [weatherTool] +}); + +for await (const item of result.getItemsStream()) { + if (item.type === 'function_call') { + // This item may be emitted multiple times as arguments grow. + // Each time, item.arguments contains the accumulated string so far. + // JSON.parse is safe on every emission — callModel heals partial JSON. + const partialArgs = JSON.parse(item.arguments); + console.log(`[${item.name}] args so far:`, partialArgs); + } + + if (item.type === 'message') { + console.log('Text:', item.content); + } +} +``` + +**Key behaviors:** + +- **Cumulative, not incremental**: Each `function_call` emission contains the full accumulated `arguments` string up to that point, not just the new delta. Consumers that track tool calls by `call_id` should **replace** the previous snapshot, not append to it. +- **JSON is always valid**: `callModel` heals partial JSON in `arguments` and structured output fields. `JSON.parse(item.arguments)` will not throw, even mid-stream. This means consumers can safely render or inspect partial tool call arguments at any point during the stream. +- **Text items accumulate similarly**: `message` items grow as text content is appended. + --- ## API Reference From 8b1a8d187f2c3bab15789aa0ec6c6a89ba016faf Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 24 Mar 2026 13:02:37 -0400 Subject: [PATCH 04/10] fix(openrouter-typescript-sdk): document getNewMessagesStream() as cumulative getNewMessagesStream() yields cumulative message snapshots, not deltas. Each message event contains the full text up to that point. Updated the section heading, description, and example to make this clear and warn against appending content across events. --- skills/openrouter-typescript-sdk/SKILL.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index a825304..541e364 100644 --- a/skills/openrouter-typescript-sdk/SKILL.md +++ b/skills/openrouter-typescript-sdk/SKILL.md @@ -1253,17 +1253,17 @@ for await (const event of result.getFullResponsesStream()) { } ``` -### Message Stream Events +### Message Stream Events (Cumulative Snapshots) -The `getNewMessagesStream()` yields OpenResponses format updates: +The `getNewMessagesStream()` yields **cumulative message snapshots, not deltas**. Each `message` event contains the full text up to that point. Replace your display text on each event rather than appending. ```typescript type MessageStreamUpdate = - | ResponsesOutputMessage // Text/content updates + | ResponsesOutputMessage // Text/content snapshots | OpenResponsesFunctionCallOutput; // Tool results ``` -### Example: Tracking New Messages +### Example: Displaying Message Stream ```typescript const result = client.callModel({ @@ -1272,19 +1272,19 @@ const result = client.callModel({ tools: [searchTool] }); -const allMessages: MessageStreamUpdate[] = []; - for await (const message of result.getNewMessagesStream()) { - allMessages.push(message); - if (message.type === 'message') { - console.log('Assistant:', message.content); + // Replace display text — message.content is the full text so far, not a delta + clearLine(); + process.stdout.write(message.content); } else if (message.type === 'function_call_output') { console.log('Tool result:', message.output); } } ``` +**Important:** Do not append `message.content` across events — each emission is the complete accumulated text. Appending will produce duplicated output. + ### Items Stream (Cumulative Emission) The `getItemsStream()` method yields response items as they build up. Unlike delta-based streams, `function_call` items are **emitted cumulatively** — the same tool call is yielded multiple times with progressively longer `arguments` as chunks arrive from the API. Each emission represents the current accumulated state of that item. From 0d8a008104bef51eec4f6f8a6a0184e91eb2c6d4 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 24 Mar 2026 13:22:55 -0400 Subject: [PATCH 05/10] fix(openrouter-typescript-sdk): unify cumulative stream documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge the getNewMessagesStream() and getItemsStream() sections under a shared "Cumulative Streams" heading. Both yield cumulative snapshots (not deltas) — the same item is emitted multiple times as it grows. Consistent framing, shared warning about replacing vs appending, and contrast with delta-based streams. --- skills/openrouter-typescript-sdk/SKILL.md | 35 +++++++++++------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index 541e364..e69220e 100644 --- a/skills/openrouter-typescript-sdk/SKILL.md +++ b/skills/openrouter-typescript-sdk/SKILL.md @@ -1253,9 +1253,17 @@ for await (const event of result.getFullResponsesStream()) { } ``` -### Message Stream Events (Cumulative Snapshots) +### Cumulative Streams: getNewMessagesStream() and getItemsStream() -The `getNewMessagesStream()` yields **cumulative message snapshots, not deltas**. Each `message` event contains the full text up to that point. Replace your display text on each event rather than appending. +Both `getNewMessagesStream()` and `getItemsStream()` yield **cumulative snapshots, not deltas**. Each emission contains the full accumulated state of that item up to that point. The same item is emitted multiple times as it grows — always **replace** your previous snapshot rather than appending. + +This differs from delta-based streams like `getTextStream()` and `getFullResponsesStream()`, which yield incremental chunks. + +`JSON.parse` on `arguments` and structured results will always succeed, even on partial emissions. The SDK heals incomplete JSON from the stream so that each cumulative snapshot is valid and parseable. + +#### getNewMessagesStream() + +Yields cumulative message-level snapshots. Each `message` event contains the full text generated so far. ```typescript type MessageStreamUpdate = @@ -1263,8 +1271,6 @@ type MessageStreamUpdate = | OpenResponsesFunctionCallOutput; // Tool results ``` -### Example: Displaying Message Stream - ```typescript const result = client.callModel({ model: 'openai/gpt-5-nano', @@ -1283,13 +1289,9 @@ for await (const message of result.getNewMessagesStream()) { } ``` -**Important:** Do not append `message.content` across events — each emission is the complete accumulated text. Appending will produce duplicated output. +#### getItemsStream() -### Items Stream (Cumulative Emission) - -The `getItemsStream()` method yields response items as they build up. Unlike delta-based streams, `function_call` items are **emitted cumulatively** — the same tool call is yielded multiple times with progressively longer `arguments` as chunks arrive from the API. Each emission represents the current accumulated state of that item. - -`JSON.parse` on `arguments` and structured results will always succeed, even on partial emissions. The SDK heals incomplete JSON from the stream so that each cumulative snapshot is valid and parseable. +Yields cumulative item-level snapshots. Each `function_call` item is emitted multiple times with progressively longer `arguments` as chunks arrive. Each `message` item grows as text content accumulates. ```typescript const result = client.callModel({ @@ -1300,24 +1302,21 @@ const result = client.callModel({ for await (const item of result.getItemsStream()) { if (item.type === 'function_call') { - // This item may be emitted multiple times as arguments grow. - // Each time, item.arguments contains the accumulated string so far. + // Emitted multiple times as arguments grow — replace, don't append. // JSON.parse is safe on every emission — callModel heals partial JSON. const partialArgs = JSON.parse(item.arguments); console.log(`[${item.name}] args so far:`, partialArgs); } if (item.type === 'message') { - console.log('Text:', item.content); + // message.content is the full text so far — replace, don't append. + clearLine(); + process.stdout.write(item.content); } } ``` -**Key behaviors:** - -- **Cumulative, not incremental**: Each `function_call` emission contains the full accumulated `arguments` string up to that point, not just the new delta. Consumers that track tool calls by `call_id` should **replace** the previous snapshot, not append to it. -- **JSON is always valid**: `callModel` heals partial JSON in `arguments` and structured output fields. `JSON.parse(item.arguments)` will not throw, even mid-stream. This means consumers can safely render or inspect partial tool call arguments at any point during the stream. -- **Text items accumulate similarly**: `message` items grow as text content is appended. +**Important:** Do not append content across events from either stream — each emission is the complete accumulated state. Appending will produce duplicated output. Track items by `call_id` (for tool calls) or by index and replace the previous snapshot on each emission. --- From dca8b14037bdb1a3048ecfcc9b3870611977a457 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 24 Mar 2026 13:49:05 -0400 Subject: [PATCH 06/10] feat(openrouter-typescript-sdk): add isComplete and parsedArguments from PR #16475 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update cumulative streams docs to match the implementation in OpenRouterTeam/openrouter-web#16475: - isComplete flag on all items from getItemsStream() and getNewMessagesStream() (false while streaming, true on final emission) - parsedArguments on function_call items in getItemsStream() — best-effort parsed object from healed JSON via healJson() utility - WithCompletion type wrapper - StreamingFunctionCallItem type with parsedArguments field - Updated type signatures, examples, and response methods table --- skills/openrouter-typescript-sdk/SKILL.md | 51 +++++++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index e69220e..1ebe41d 100644 --- a/skills/openrouter-typescript-sdk/SKILL.md +++ b/skills/openrouter-typescript-sdk/SKILL.md @@ -347,7 +347,8 @@ The result object provides multiple methods for consuming the response: | `getTextStream()` | Stream text deltas as they arrive | | `getReasoningStream()` | Stream reasoning tokens (for o1/reasoning models) | | `getToolCallsStream()` | Stream tool calls as they complete | -| `getItemsStream()` | Stream response items cumulatively as they build up | +| `getItemsStream()` | Stream cumulative item snapshots with `isComplete` flag and `parsedArguments` | +| `getNewMessagesStream()` | Stream cumulative message snapshots with `isComplete` flag | ### getText() @@ -1259,16 +1260,39 @@ Both `getNewMessagesStream()` and `getItemsStream()` yield **cumulative snapshot This differs from delta-based streams like `getTextStream()` and `getFullResponsesStream()`, which yield incremental chunks. -`JSON.parse` on `arguments` and structured results will always succeed, even on partial emissions. The SDK heals incomplete JSON from the stream so that each cumulative snapshot is valid and parseable. +#### isComplete Flag + +Every item from both streams carries an `isComplete: boolean` field: + +- `false` — the item is still receiving data (more emissions will follow for this item) +- `true` — this is the final emission for this item + +Use `isComplete` to distinguish in-progress snapshots from the finished version without needing to track item IDs yourself. + +```typescript +type WithCompletion = T & { isComplete: boolean }; +``` + +#### parsedArguments (getItemsStream only) + +`function_call` items in `getItemsStream()` include a `parsedArguments` field — a best-effort parsed object produced by the SDK's internal `healJson()` utility, which closes truncated strings, objects, and arrays in the accumulated `arguments` string. This means `parsedArguments` is always a valid object (or `undefined` if healing fails), even mid-stream when `arguments` is still an incomplete JSON string. + +```typescript +// StreamingFunctionCallItem — the function_call type in getItemsStream() +type StreamingFunctionCallItem = ResponsesOutputItemFunctionCall & { + parsedArguments?: Record | undefined; +}; +``` #### getNewMessagesStream() -Yields cumulative message-level snapshots. Each `message` event contains the full text generated so far. +Yields cumulative message-level snapshots. Each `message` event contains the full text generated so far, plus `isComplete` to indicate whether the message is still streaming. ```typescript type MessageStreamUpdate = - | ResponsesOutputMessage // Text/content snapshots - | OpenResponsesFunctionCallOutput; // Tool results + | WithCompletion // Text/content snapshots with isComplete + | OpenResponsesFunctionCallOutput // Tool results + | ResponsesOutputItemFunctionCall; // Function calls ``` ```typescript @@ -1283,6 +1307,10 @@ for await (const message of result.getNewMessagesStream()) { // Replace display text — message.content is the full text so far, not a delta clearLine(); process.stdout.write(message.content); + + if (message.isComplete) { + console.log('\n[Message complete]'); + } } else if (message.type === 'function_call_output') { console.log('Tool result:', message.output); } @@ -1291,7 +1319,7 @@ for await (const message of result.getNewMessagesStream()) { #### getItemsStream() -Yields cumulative item-level snapshots. Each `function_call` item is emitted multiple times with progressively longer `arguments` as chunks arrive. Each `message` item grows as text content accumulates. +Yields cumulative item-level snapshots. Each `function_call` item is emitted multiple times with progressively longer `arguments` and a healed `parsedArguments` object. Each `message` item grows as text content accumulates. All items carry `isComplete`. ```typescript const result = client.callModel({ @@ -1303,9 +1331,14 @@ const result = client.callModel({ for await (const item of result.getItemsStream()) { if (item.type === 'function_call') { // Emitted multiple times as arguments grow — replace, don't append. - // JSON.parse is safe on every emission — callModel heals partial JSON. - const partialArgs = JSON.parse(item.arguments); - console.log(`[${item.name}] args so far:`, partialArgs); + // Use parsedArguments for safe access to partial args (healed JSON). + console.log(`[${item.name}] args so far:`, item.parsedArguments); + + if (item.isComplete) { + // Final emission — arguments is now complete valid JSON + const finalArgs = JSON.parse(item.arguments); + console.log(`[${item.name}] final args:`, finalArgs); + } } if (item.type === 'message') { From 9e38f82de0cce998db4ae2419886889f5d36a62f Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 24 Mar 2026 13:55:46 -0400 Subject: [PATCH 07/10] refactor(openrouter-typescript-sdk): rename to arguments/rawArguments Rename streaming function_call fields so the healed parsed object is the primary 'arguments' field and the raw accumulated JSON string is 'rawArguments'. This makes the common case (accessing parsed args) the default, with the raw string available when needed. --- skills/openrouter-typescript-sdk/SKILL.md | 27 ++++++++++++++--------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index 1ebe41d..cd861ac 100644 --- a/skills/openrouter-typescript-sdk/SKILL.md +++ b/skills/openrouter-typescript-sdk/SKILL.md @@ -347,7 +347,7 @@ The result object provides multiple methods for consuming the response: | `getTextStream()` | Stream text deltas as they arrive | | `getReasoningStream()` | Stream reasoning tokens (for o1/reasoning models) | | `getToolCallsStream()` | Stream tool calls as they complete | -| `getItemsStream()` | Stream cumulative item snapshots with `isComplete` flag and `parsedArguments` | +| `getItemsStream()` | Stream cumulative item snapshots with `isComplete` flag and healed `arguments` | | `getNewMessagesStream()` | Stream cumulative message snapshots with `isComplete` flag | ### getText() @@ -1273,14 +1273,18 @@ Use `isComplete` to distinguish in-progress snapshots from the finished version type WithCompletion = T & { isComplete: boolean }; ``` -#### parsedArguments (getItemsStream only) +#### arguments and rawArguments (getItemsStream only) -`function_call` items in `getItemsStream()` include a `parsedArguments` field — a best-effort parsed object produced by the SDK's internal `healJson()` utility, which closes truncated strings, objects, and arrays in the accumulated `arguments` string. This means `parsedArguments` is always a valid object (or `undefined` if healing fails), even mid-stream when `arguments` is still an incomplete JSON string. +`function_call` items in `getItemsStream()` expose two argument fields: + +- **`arguments`** — a best-effort parsed object produced by the SDK's internal `healJson()` utility, which closes truncated strings, objects, and arrays. Always a valid object (or `undefined` if healing fails), safe to use at any point during the stream. +- **`rawArguments`** — the raw accumulated JSON string from the API, which may be incomplete/unparseable mid-stream. ```typescript // StreamingFunctionCallItem — the function_call type in getItemsStream() -type StreamingFunctionCallItem = ResponsesOutputItemFunctionCall & { - parsedArguments?: Record | undefined; +type StreamingFunctionCallItem = Omit & { + arguments?: Record | undefined; // Healed, always valid + rawArguments: string; // Raw accumulated string }; ``` @@ -1319,7 +1323,7 @@ for await (const message of result.getNewMessagesStream()) { #### getItemsStream() -Yields cumulative item-level snapshots. Each `function_call` item is emitted multiple times with progressively longer `arguments` and a healed `parsedArguments` object. Each `message` item grows as text content accumulates. All items carry `isComplete`. +Yields cumulative item-level snapshots. Each `function_call` item is emitted multiple times with a progressively more complete `arguments` object (healed) and `rawArguments` string. Each `message` item grows as text content accumulates. All items carry `isComplete`. ```typescript const result = client.callModel({ @@ -1331,13 +1335,14 @@ const result = client.callModel({ for await (const item of result.getItemsStream()) { if (item.type === 'function_call') { // Emitted multiple times as arguments grow — replace, don't append. - // Use parsedArguments for safe access to partial args (healed JSON). - console.log(`[${item.name}] args so far:`, item.parsedArguments); + // item.arguments is the healed parsed object — safe to use mid-stream. + console.log(`[${item.name}] args so far:`, item.arguments); if (item.isComplete) { - // Final emission — arguments is now complete valid JSON - const finalArgs = JSON.parse(item.arguments); - console.log(`[${item.name}] final args:`, finalArgs); + // Final emission — arguments is the complete parsed object. + // rawArguments is the complete JSON string if needed. + console.log(`[${item.name}] final args:`, item.arguments); + console.log(`[${item.name}] raw JSON:`, item.rawArguments); } } From 8bc6d99ff32a61f0cb04a08481217ff68c98cf79 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 24 Mar 2026 16:18:35 -0400 Subject: [PATCH 08/10] fix(openrouter-typescript-sdk): clarify manual tools are for human-in-the-loop only Manual tools (execute: false) are specifically for flows where a human produces the tool call result. Updated descriptions, examples, and contrast with approval flows throughout. Replaced programmatic examples (db queries, deploys) with human-in-the-loop examples (asking the user questions, collecting preferences). --- skills/openrouter-typescript-sdk/SKILL.md | 57 ++++++++++++----------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index cd861ac..d3088fb 100644 --- a/skills/openrouter-typescript-sdk/SKILL.md +++ b/skills/openrouter-typescript-sdk/SKILL.md @@ -476,7 +476,9 @@ const searchTool = tool({ ``` #### Manual Tools -Set `execute: false` to handle tool execution entirely outside the SDK. When the model calls a manual tool during the `callModel` loop, the loop exits early and returns. The model's response (including the tool call) is available in the response output. Retrieve the tool calls, execute them externally, and pass results back as `function_call_output` messages on the next `callModel` call. See [Manual Tool Execution](#manual-tool-execution) for the full lifecycle. +Set `execute: false` for human-in-the-loop flows where a person produces the tool call result. When the model calls a manual tool during the `callModel` loop, the loop exits early and returns. The model's response (including the tool call) is available in the response output. Present the tool call to the user, collect their response, and pass it back as a `function_call_output` message on the next `callModel` call. See [Manual Tool Execution](#manual-tool-execution) for the full lifecycle. + +**Note:** Manual tools are specifically for cases where a human provides the result. For programmatic tool execution that needs approval before running, use [Approval Flows](#approval-flows) instead. ```typescript const manualTool = tool({ @@ -535,7 +537,9 @@ const result = client.callModel({ ## Manual Tool Execution -When the model calls a manual tool (`execute: false`) during the `callModel` loop, the loop exits early and returns. The model's response — including the tool call — is in the response output. The developer is responsible for executing the tool externally and passing results back on the next `callModel` invocation. +Manual tools (`execute: false`) are for **human-in-the-loop flows** where a person — not code — produces the tool call result. When the model calls a manual tool, the `callModel` loop exits early and returns. The model's response — including the tool call — is in the response output. Present the tool call to the user, collect their input, and pass it back as a `function_call_output` on the next `callModel` invocation. + +If the tool result is produced by code and just needs approval before running, use [Approval Flows](#approval-flows) instead — the SDK manages the execution once approved. ### Manual Tool Lifecycle @@ -547,37 +551,37 @@ const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }); -// 1. Define a manual tool -const runQueryTool = tool({ - name: 'run_query', - description: 'Execute a database query', +// 1. Define a manual tool — the human provides the result +const askUserTool = tool({ + name: 'ask_user', + description: 'Ask the user a clarifying question', inputSchema: z.object({ - sql: z.string().describe('The SQL query to execute') + question: z.string().describe('The question to ask the user') }), - execute: false // SDK will not execute this — loop exits when called + execute: false // Human-in-the-loop — loop exits when called }); // 2. Call the model — loop exits when the manual tool is called const result = client.callModel({ model: 'openai/gpt-5-nano', - input: 'How many users signed up last week?', - tools: [runQueryTool] + input: 'Help me plan a trip to Japan', + tools: [askUserTool] }); const response = await result.getResponse(); // 3. Extract the tool call from response output const toolCall = response.output.find( - (item) => item.type === 'function_call' && item.name === 'run_query' + (item) => item.type === 'function_call' && item.name === 'ask_user' ); if (toolCall) { const args = JSON.parse(toolCall.arguments); - // 4. Execute the tool externally - const queryResult = await db.query(args.sql); + // 4. Present the question to the user and collect their response + const userAnswer = await promptUser(args.question); - // 5. Pass the result back on the next callModel call + // 5. Pass the human's response back on the next callModel call const followUp = client.callModel({ model: 'openai/gpt-5-nano', input: [ @@ -586,10 +590,10 @@ if (toolCall) { { type: 'function_call_output', call_id: toolCall.call_id, - output: JSON.stringify(queryResult) + output: JSON.stringify({ answer: userAnswer }) } ], - tools: [runQueryTool] + tools: [askUserTool] }); const text = await followUp.getText(); @@ -611,24 +615,25 @@ const searchTool = tool({ } }); -const deployTool = tool({ - name: 'deploy', - description: 'Deploy to production', +const getUserInputTool = tool({ + name: 'get_user_preference', + description: 'Ask the user for their preference', inputSchema: z.object({ - service: z.string().describe('Service to deploy'), - version: z.string().describe('Version tag') + question: z.string().describe('The question to ask'), + options: z.array(z.string()).describe('Available options') }), - execute: false // Developer handles this externally + execute: false // Human provides the answer }); const result = client.callModel({ model: 'openai/gpt-5-nano', - input: 'Find the latest stable version of the auth service and deploy it', - tools: [searchTool, deployTool] + input: 'Help me pick a restaurant for dinner tonight', + tools: [searchTool, getUserInputTool] }); // The SDK automatically executes search calls in the loop. -// When the model calls deploy, the loop exits and returns. +// When the model calls get_user_preference, the loop exits so the +// human can answer, and their response is passed back on the next call. const response = await result.getResponse(); ``` @@ -636,7 +641,7 @@ const response = await result.getResponse(); ## Approval Flows -The SDK provides a built-in approval system for tools that should execute automatically *once approved*, but pause the loop when approval has not yet been granted. Unlike manual tools (where the developer always handles execution externally), approval-gated tools have an `execute` function — the SDK just won't call it until the tool call is approved. +The SDK provides a built-in approval system for tools that should execute automatically *once approved*, but pause the loop when approval has not yet been granted. Unlike manual tools (where a human produces the result), approval-gated tools have an `execute` function that runs code — the SDK just won't call it until the tool call is approved. Approval state is persisted across `callModel` invocations via a `state` accessor, so the developer can inspect pending tool calls, present them to the user, and provide approval or rejection decisions on the next call. From 86efe1a2e43939da0f21f60e76a340a151e08088 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 24 Mar 2026 16:20:52 -0400 Subject: [PATCH 09/10] fix(openrouter-typescript-sdk): manual tools do not support approval flows Remove misleading cross-references suggesting approval flows as an alternative to manual tools. These are separate, independent mechanisms. --- skills/openrouter-typescript-sdk/SKILL.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index d3088fb..f5ec596 100644 --- a/skills/openrouter-typescript-sdk/SKILL.md +++ b/skills/openrouter-typescript-sdk/SKILL.md @@ -478,8 +478,6 @@ const searchTool = tool({ #### Manual Tools Set `execute: false` for human-in-the-loop flows where a person produces the tool call result. When the model calls a manual tool during the `callModel` loop, the loop exits early and returns. The model's response (including the tool call) is available in the response output. Present the tool call to the user, collect their response, and pass it back as a `function_call_output` message on the next `callModel` call. See [Manual Tool Execution](#manual-tool-execution) for the full lifecycle. -**Note:** Manual tools are specifically for cases where a human provides the result. For programmatic tool execution that needs approval before running, use [Approval Flows](#approval-flows) instead. - ```typescript const manualTool = tool({ name: 'user_confirmation', @@ -539,7 +537,7 @@ const result = client.callModel({ Manual tools (`execute: false`) are for **human-in-the-loop flows** where a person — not code — produces the tool call result. When the model calls a manual tool, the `callModel` loop exits early and returns. The model's response — including the tool call — is in the response output. Present the tool call to the user, collect their input, and pass it back as a `function_call_output` on the next `callModel` invocation. -If the tool result is produced by code and just needs approval before running, use [Approval Flows](#approval-flows) instead — the SDK manages the execution once approved. +Manual tools do not support approval flows — they are a separate mechanism. ### Manual Tool Lifecycle From 5bd757123270c1e7163c6c7ba0993e559356978c Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 24 Mar 2026 16:27:08 -0400 Subject: [PATCH 10/10] refactor(openrouter-typescript-sdk): move reference material to references/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move detailed authentication (OAuth, key management) and type/event shape definitions to references/ files per progressive disclosure principle. SKILL.md retains core procedural knowledge and behavioral docs (cumulative streams, manual tools, approval flows). Reduces SKILL.md from 1575 to 1045 lines. New files: - references/authentication.md — OAuth flow, key management, security - references/types-and-events.md — message shapes, event interfaces, stream types --- skills/openrouter-typescript-sdk/SKILL.md | 537 +----------------- .../references/authentication.md | 179 ++++++ .../references/types-and-events.md | 277 +++++++++ 3 files changed, 460 insertions(+), 533 deletions(-) create mode 100644 skills/openrouter-typescript-sdk/references/authentication.md create mode 100644 skills/openrouter-typescript-sdk/references/types-and-events.md diff --git a/skills/openrouter-typescript-sdk/SKILL.md b/skills/openrouter-typescript-sdk/SKILL.md index f5ec596..dee5dcd 100644 --- a/skills/openrouter-typescript-sdk/SKILL.md +++ b/skills/openrouter-typescript-sdk/SKILL.md @@ -6,7 +6,7 @@ version: 1.0.0 # OpenRouter TypeScript SDK -A comprehensive TypeScript SDK for interacting with OpenRouter's unified API, providing access to 300+ AI models through a single, type-safe interface. This skill enables AI agents to leverage the `callModel` pattern for text generation, tool usage, streaming, and multi-turn conversations. +Integrate with 300+ AI models through the `callModel` pattern for text generation, tool usage, streaming, and multi-turn conversations. --- @@ -32,224 +32,7 @@ const client = new OpenRouter({ ## Authentication -The SDK supports two authentication methods: API keys for server-side applications and OAuth PKCE flow for user-facing applications. - -### API Key Authentication - -The primary authentication method uses API keys from your OpenRouter account. - -#### Obtaining an API Key - -1. Visit [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) -2. Create a new API key -3. Store securely in an environment variable - -#### Environment Setup - -```bash -export OPENROUTER_API_KEY=sk-or-v1-your-key-here -``` - -#### Client Initialization - -```typescript -import OpenRouter from '@openrouter/sdk'; - -const client = new OpenRouter({ - apiKey: process.env.OPENROUTER_API_KEY -}); -``` - -The client automatically uses this key for all subsequent requests: - -```typescript -// API key is automatically included -const result = client.callModel({ - model: 'openai/gpt-5-nano', - input: 'Hello!' -}); -``` - -#### Get Current Key Metadata - -Retrieve information about the currently configured API key: - -```typescript -const keyInfo = await client.apiKeys.getCurrentKeyMetadata(); -console.log('Key name:', keyInfo.name); -console.log('Created:', keyInfo.createdAt); -``` - -#### API Key Management - -Programmatically manage API keys: - -```typescript -// List all keys -const keys = await client.apiKeys.list(); - -// Create a new key -const newKey = await client.apiKeys.create({ - name: 'Production API Key' -}); - -// Get a specific key by hash -const key = await client.apiKeys.get({ - hash: 'sk-or-v1-...' -}); - -// Update a key -await client.apiKeys.update({ - hash: 'sk-or-v1-...', - requestBody: { - name: 'Updated Key Name' - } -}); - -// Delete a key -await client.apiKeys.delete({ - hash: 'sk-or-v1-...' -}); -``` - -### OAuth Authentication (PKCE Flow) - -For user-facing applications where users should control their own API keys, OpenRouter supports OAuth with PKCE (Proof Key for Code Exchange). This flow allows users to generate API keys through a browser authorization flow without your application handling their credentials. - -#### createAuthCode - -Generate an authorization code and URL to start the OAuth flow: - -```typescript -const authResponse = await client.oAuth.createAuthCode({ - callbackUrl: 'https://myapp.com/auth/callback' -}); - -// authResponse contains: -// - authorizationUrl: URL to redirect the user to -// - code: The authorization code for later exchange - -console.log('Redirect user to:', authResponse.authorizationUrl); -``` - -**Parameters:** - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `callbackUrl` | `string` | Yes | Your application's callback URL after user authorization | - -**Browser Redirect:** - -```typescript -// In a browser environment -window.location.href = authResponse.authorizationUrl; - -// Or in a server-rendered app, return a redirect response -res.redirect(authResponse.authorizationUrl); -``` - -#### exchangeAuthCodeForAPIKey - -After the user authorizes your application, they are redirected back to your callback URL with an authorization code. Exchange this code for an API key: - -```typescript -// In your callback handler -const code = req.query.code; // From the redirect URL - -const apiKeyResponse = await client.oAuth.exchangeAuthCodeForAPIKey({ - code: code -}); - -// apiKeyResponse contains: -// - key: The user's API key -// - Additional metadata about the key - -const userApiKey = apiKeyResponse.key; - -// Store securely for this user's future requests -await saveUserApiKey(userId, userApiKey); -``` - -**Parameters:** - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `code` | `string` | Yes | The authorization code from the OAuth redirect | - -#### Complete OAuth Flow Example - -```typescript -import OpenRouter from '@openrouter/sdk'; -import express from 'express'; - -const app = express(); -const client = new OpenRouter({ - apiKey: process.env.OPENROUTER_API_KEY // Your app's key for OAuth operations -}); - -// Step 1: Initiate OAuth flow -app.get('/auth/start', async (req, res) => { - const authResponse = await client.oAuth.createAuthCode({ - callbackUrl: 'https://myapp.com/auth/callback' - }); - - // Store any state needed for the callback - req.session.oauthState = { /* ... */ }; - - // Redirect user to OpenRouter authorization page - res.redirect(authResponse.authorizationUrl); -}); - -// Step 2: Handle callback and exchange code -app.get('/auth/callback', async (req, res) => { - const { code } = req.query; - - if (!code) { - return res.status(400).send('Authorization code missing'); - } - - try { - const apiKeyResponse = await client.oAuth.exchangeAuthCodeForAPIKey({ - code: code as string - }); - - // Store the user's API key securely - await saveUserApiKey(req.session.userId, apiKeyResponse.key); - - res.redirect('/dashboard?auth=success'); - } catch (error) { - console.error('OAuth exchange failed:', error); - res.redirect('/auth/error'); - } -}); - -// Step 3: Use the user's API key for their requests -app.post('/api/chat', async (req, res) => { - const userApiKey = await getUserApiKey(req.session.userId); - - // Create a client with the user's key - const userClient = new OpenRouter({ - apiKey: userApiKey - }); - - const result = userClient.callModel({ - model: 'openai/gpt-5-nano', - input: req.body.message - }); - - const text = await result.getText(); - res.json({ response: text }); -}); -``` - -### Security Best Practices - -1. **Environment Variables**: Store API keys in environment variables, never in code -2. **Key Rotation**: Rotate keys periodically using the key management API -3. **Environment Separation**: Use different keys for development, staging, and production -4. **OAuth for Users**: Use the OAuth PKCE flow for user-facing apps to avoid handling user credentials -5. **Secure Storage**: Store user API keys encrypted in your database -6. **Minimal Scope**: Create keys with only the permissions needed +The SDK supports API key authentication and OAuth PKCE for user-facing apps. For detailed key management, OAuth flow, and security best practices, see `references/authentication.md`. --- @@ -941,321 +724,9 @@ const claudeMsg = toClaudeMessage(response); --- -## Responses API Message Shapes - -The SDK uses the **OpenResponses** format for messages. Understanding these shapes is essential for building robust agents. - -### Message Roles - -Messages contain a `role` property that determines the message type: - -| Role | Description | -|------|-------------| -| `user` | User-provided input | -| `assistant` | Model-generated responses | -| `system` | System instructions | -| `developer` | Developer-level directives | -| `tool` | Tool execution results | - -### Text Message - -Simple text content from user or assistant: - -```typescript -interface TextMessage { - role: 'user' | 'assistant'; - content: string; -} -``` - -### Multimodal Message (Array Content) - -Messages with mixed content types: - -```typescript -interface MultimodalMessage { - role: 'user'; - content: Array< - | { type: 'input_text'; text: string } - | { type: 'input_image'; imageUrl: string; detail?: 'auto' | 'low' | 'high' } - | { - type: 'image'; - source: { - type: 'url' | 'base64'; - url?: string; - media_type?: string; - data?: string - } - } - >; -} -``` - -### Tool Function Call Message - -When the model requests a tool execution: - -```typescript -interface ToolCallMessage { - role: 'assistant'; - content?: null; - tool_calls?: Array<{ - id: string; - type: 'function'; - function: { - name: string; - arguments: string; // JSON-encoded arguments - }; - }>; -} -``` - -### Tool Result Message - -Result returned after tool execution: - -```typescript -interface ToolResultMessage { - role: 'tool'; - tool_call_id: string; - content: string; // JSON-encoded result -} -``` - -### Non-Streaming Response Structure - -The complete response object from `getResponse()`: - -```typescript -interface OpenResponsesNonStreamingResponse { - output: Array; - usage?: { - inputTokens: number; - outputTokens: number; - cachedTokens?: number; - }; - finishReason?: string; - warnings?: Array<{ - type: string; - message: string - }>; - experimental_providerMetadata?: Record; -} -``` - -### Response Message Types - -Output messages in the response array: - -```typescript -// Text/content message -interface ResponseOutputMessage { - type: 'message'; - role: 'assistant'; - content: string | Array; - reasoning?: string; // For reasoning models (o1, etc.) -} - -// Tool result in output -interface FunctionCallOutputMessage { - type: 'function_call_output'; - call_id: string; - output: string; -} -``` - -### Parsed Tool Call - -When tool calls are parsed from the response: - -```typescript -interface ParsedToolCall { - id: string; - name: string; - arguments: unknown; // Validated against inputSchema -} -``` - -### Tool Execution Result - -After a tool completes execution: - -```typescript -interface ToolExecutionResult { - toolCallId: string; - toolName: string; - result: unknown; // Validated against outputSchema - preliminaryResults?: unknown[]; // From generator tools - error?: Error; -} -``` - -### Step Result (for Stop Conditions) - -Available in custom stop condition callbacks: - -```typescript -interface StepResult { - stepType: 'initial' | 'continue'; - text: string; - toolCalls: ParsedToolCall[]; - toolResults: ToolExecutionResult[]; - response: OpenResponsesNonStreamingResponse; - usage?: { - inputTokens: number; - outputTokens: number; - cachedTokens?: number; - }; - finishReason?: string; - warnings?: Array<{ type: string; message: string }>; - experimental_providerMetadata?: Record; -} -``` - -### TurnContext - -Available to tools and dynamic parameter functions: - -```typescript -interface TurnContext { - numberOfTurns: number; // Turn count (1-indexed) - turnRequest?: OpenResponsesRequest; // Current request being made - toolCall?: OpenResponsesFunctionToolCall; // Current tool call (in tool context) -} -``` - ---- - -## Event Shapes - -The SDK provides multiple streaming methods that yield different event types. - -### Response Stream Events - -The `getFullResponsesStream()` method yields these event types: - -```typescript -type EnhancedResponseStreamEvent = - | ResponseCreatedEvent - | ResponseInProgressEvent - | OutputTextDeltaEvent - | OutputTextDoneEvent - | ReasoningDeltaEvent - | ReasoningDoneEvent - | FunctionCallArgumentsDeltaEvent - | FunctionCallArgumentsDoneEvent - | ResponseCompletedEvent - | ToolPreliminaryResultEvent; -``` - -### Event Type Reference - -| Event Type | Description | Payload | -|------------|-------------|---------| -| `response.created` | Response object initialized | `{ response: ResponseObject }` | -| `response.in_progress` | Generation has started | `{}` | -| `response.output_text.delta` | Text chunk received | `{ delta: string }` | -| `response.output_text.done` | Text generation complete | `{ text: string }` | -| `response.reasoning.delta` | Reasoning chunk (o1 models) | `{ delta: string }` | -| `response.reasoning.done` | Reasoning complete | `{ reasoning: string }` | -| `response.function_call_arguments.delta` | Tool argument chunk | `{ delta: string }` | -| `response.function_call_arguments.done` | Tool arguments complete | `{ arguments: string }` | -| `response.completed` | Full response complete | `{ response: ResponseObject }` | -| `tool.preliminary_result` | Generator tool progress | `{ toolCallId: string; result: unknown }` | - -### Text Delta Event - -```typescript -interface OutputTextDeltaEvent { - type: 'response.output_text.delta'; - delta: string; -} -``` - -### Reasoning Delta Event - -For reasoning models (o1, etc.): - -```typescript -interface ReasoningDeltaEvent { - type: 'response.reasoning.delta'; - delta: string; -} -``` - -### Function Call Arguments Delta Event - -```typescript -interface FunctionCallArgumentsDeltaEvent { - type: 'response.function_call_arguments.delta'; - delta: string; -} -``` - -### Tool Preliminary Result Event +## Types and Event Shapes -From generator tools that yield progress: - -```typescript -interface ToolPreliminaryResultEvent { - type: 'tool.preliminary_result'; - toolCallId: string; - result: unknown; // Matches the tool's eventSchema -} -``` - -### Response Completed Event - -```typescript -interface ResponseCompletedEvent { - type: 'response.completed'; - response: OpenResponsesNonStreamingResponse; -} -``` - -### Tool Stream Events - -The `getToolStream()` method yields: - -```typescript -type ToolStreamEvent = - | { type: 'delta'; content: string } - | { type: 'preliminary_result'; toolCallId: string; result: unknown }; -``` - -### Example: Processing Stream Events - -```typescript -const result = client.callModel({ - model: 'openai/gpt-5-nano', - input: 'Analyze this data', - tools: [analysisTool] -}); - -for await (const event of result.getFullResponsesStream()) { - switch (event.type) { - case 'response.output_text.delta': - process.stdout.write(event.delta); - break; - - case 'response.reasoning.delta': - console.log('[Reasoning]', event.delta); - break; - - case 'response.function_call_arguments.delta': - console.log('[Tool Args]', event.delta); - break; - - case 'tool.preliminary_result': - console.log(`[Progress: ${event.toolCallId}]`, event.result); - break; - - case 'response.completed': - console.log('\n[Complete]', event.response.usage); - break; - } -} -``` +For complete type interfaces (message shapes, response structures, TurnContext, StepResult) and streaming event shapes (delta events, response events, tool stream events), see `references/types-and-events.md`. Search for specific types with: `grep -n "interface\|type " references/types-and-events.md` ### Cumulative Streams: getNewMessagesStream() and getItemsStream() diff --git a/skills/openrouter-typescript-sdk/references/authentication.md b/skills/openrouter-typescript-sdk/references/authentication.md new file mode 100644 index 0000000..3bd7d3b --- /dev/null +++ b/skills/openrouter-typescript-sdk/references/authentication.md @@ -0,0 +1,179 @@ +# Authentication Reference + +## API Key Authentication + +### Obtaining an API Key + +1. Visit [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) +2. Create a new API key +3. Store securely in an environment variable + +### Environment Setup + +```bash +export OPENROUTER_API_KEY=sk-or-v1-your-key-here +``` + +### Get Current Key Metadata + +```typescript +const keyInfo = await client.apiKeys.getCurrentKeyMetadata(); +console.log('Key name:', keyInfo.name); +console.log('Created:', keyInfo.createdAt); +``` + +### API Key Management + +```typescript +// List all keys +const keys = await client.apiKeys.list(); + +// Create a new key +const newKey = await client.apiKeys.create({ + name: 'Production API Key' +}); + +// Get a specific key by hash +const key = await client.apiKeys.get({ + hash: 'sk-or-v1-...' +}); + +// Update a key +await client.apiKeys.update({ + hash: 'sk-or-v1-...', + requestBody: { + name: 'Updated Key Name' + } +}); + +// Delete a key +await client.apiKeys.delete({ + hash: 'sk-or-v1-...' +}); +``` + +## OAuth Authentication (PKCE Flow) + +For user-facing applications where users control their own API keys, OpenRouter supports OAuth with PKCE (Proof Key for Code Exchange). + +### createAuthCode + +Generate an authorization code and URL to start the OAuth flow: + +```typescript +const authResponse = await client.oAuth.createAuthCode({ + callbackUrl: 'https://myapp.com/auth/callback' +}); + +// authResponse contains: +// - authorizationUrl: URL to redirect the user to +// - code: The authorization code for later exchange + +console.log('Redirect user to:', authResponse.authorizationUrl); +``` + +**Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `callbackUrl` | `string` | Yes | Application callback URL after user authorization | + +**Browser Redirect:** + +```typescript +// In a browser environment +window.location.href = authResponse.authorizationUrl; + +// Or in a server-rendered app, return a redirect response +res.redirect(authResponse.authorizationUrl); +``` + +### exchangeAuthCodeForAPIKey + +After the user authorizes, exchange the code for an API key: + +```typescript +const code = req.query.code; // From the redirect URL + +const apiKeyResponse = await client.oAuth.exchangeAuthCodeForAPIKey({ + code: code +}); + +const userApiKey = apiKeyResponse.key; +await saveUserApiKey(userId, userApiKey); +``` + +**Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `code` | `string` | Yes | The authorization code from the OAuth redirect | + +### Complete OAuth Flow Example + +```typescript +import OpenRouter from '@openrouter/sdk'; +import express from 'express'; + +const app = express(); +const client = new OpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY // App's key for OAuth operations +}); + +// Step 1: Initiate OAuth flow +app.get('/auth/start', async (req, res) => { + const authResponse = await client.oAuth.createAuthCode({ + callbackUrl: 'https://myapp.com/auth/callback' + }); + + req.session.oauthState = { /* ... */ }; + res.redirect(authResponse.authorizationUrl); +}); + +// Step 2: Handle callback and exchange code +app.get('/auth/callback', async (req, res) => { + const { code } = req.query; + + if (!code) { + return res.status(400).send('Authorization code missing'); + } + + try { + const apiKeyResponse = await client.oAuth.exchangeAuthCodeForAPIKey({ + code: code as string + }); + + await saveUserApiKey(req.session.userId, apiKeyResponse.key); + res.redirect('/dashboard?auth=success'); + } catch (error) { + console.error('OAuth exchange failed:', error); + res.redirect('/auth/error'); + } +}); + +// Step 3: Use the user's API key for their requests +app.post('/api/chat', async (req, res) => { + const userApiKey = await getUserApiKey(req.session.userId); + + const userClient = new OpenRouter({ + apiKey: userApiKey + }); + + const result = userClient.callModel({ + model: 'openai/gpt-5-nano', + input: req.body.message + }); + + const text = await result.getText(); + res.json({ response: text }); +}); +``` + +## Security Best Practices + +1. **Environment Variables**: Store API keys in environment variables, never in code +2. **Key Rotation**: Rotate keys periodically using the key management API +3. **Environment Separation**: Use different keys for development, staging, and production +4. **OAuth for Users**: Use the OAuth PKCE flow for user-facing apps +5. **Secure Storage**: Store user API keys encrypted in the database +6. **Minimal Scope**: Create keys with only the permissions needed diff --git a/skills/openrouter-typescript-sdk/references/types-and-events.md b/skills/openrouter-typescript-sdk/references/types-and-events.md new file mode 100644 index 0000000..9bbdc5b --- /dev/null +++ b/skills/openrouter-typescript-sdk/references/types-and-events.md @@ -0,0 +1,277 @@ +# Types and Event Shapes Reference + +## Responses API Message Shapes + +The SDK uses the **OpenResponses** format for messages. + +### Message Roles + +| Role | Description | +|------|-------------| +| `user` | User-provided input | +| `assistant` | Model-generated responses | +| `system` | System instructions | +| `developer` | Developer-level directives | +| `tool` | Tool execution results | + +### Text Message + +```typescript +interface TextMessage { + role: 'user' | 'assistant'; + content: string; +} +``` + +### Multimodal Message (Array Content) + +```typescript +interface MultimodalMessage { + role: 'user'; + content: Array< + | { type: 'input_text'; text: string } + | { type: 'input_image'; imageUrl: string; detail?: 'auto' | 'low' | 'high' } + | { + type: 'image'; + source: { + type: 'url' | 'base64'; + url?: string; + media_type?: string; + data?: string + } + } + >; +} +``` + +### Tool Function Call Message + +```typescript +interface ToolCallMessage { + role: 'assistant'; + content?: null; + tool_calls?: Array<{ + id: string; + type: 'function'; + function: { + name: string; + arguments: string; // JSON-encoded arguments + }; + }>; +} +``` + +### Tool Result Message + +```typescript +interface ToolResultMessage { + role: 'tool'; + tool_call_id: string; + content: string; // JSON-encoded result +} +``` + +### Non-Streaming Response Structure + +```typescript +interface OpenResponsesNonStreamingResponse { + output: Array; + usage?: { + inputTokens: number; + outputTokens: number; + cachedTokens?: number; + }; + finishReason?: string; + warnings?: Array<{ + type: string; + message: string + }>; + experimental_providerMetadata?: Record; +} +``` + +### Response Message Types + +```typescript +// Text/content message +interface ResponseOutputMessage { + type: 'message'; + role: 'assistant'; + content: string | Array; + reasoning?: string; // For reasoning models (o1, etc.) +} + +// Tool result in output +interface FunctionCallOutputMessage { + type: 'function_call_output'; + call_id: string; + output: string; +} +``` + +### Parsed Tool Call + +```typescript +interface ParsedToolCall { + id: string; + name: string; + arguments: unknown; // Validated against inputSchema +} +``` + +### Tool Execution Result + +```typescript +interface ToolExecutionResult { + toolCallId: string; + toolName: string; + result: unknown; // Validated against outputSchema + preliminaryResults?: unknown[]; // From generator tools + error?: Error; +} +``` + +### Step Result (for Stop Conditions) + +```typescript +interface StepResult { + stepType: 'initial' | 'continue'; + text: string; + toolCalls: ParsedToolCall[]; + toolResults: ToolExecutionResult[]; + response: OpenResponsesNonStreamingResponse; + usage?: { + inputTokens: number; + outputTokens: number; + cachedTokens?: number; + }; + finishReason?: string; + warnings?: Array<{ type: string; message: string }>; + experimental_providerMetadata?: Record; +} +``` + +### TurnContext + +```typescript +interface TurnContext { + numberOfTurns: number; // Turn count (1-indexed) + turnRequest?: OpenResponsesRequest; // Current request being made + toolCall?: OpenResponsesFunctionToolCall; // Current tool call (in tool context) +} +``` + +--- + +## Event Shapes + +### Response Stream Events + +The `getFullResponsesStream()` method yields these event types: + +```typescript +type EnhancedResponseStreamEvent = + | ResponseCreatedEvent + | ResponseInProgressEvent + | OutputTextDeltaEvent + | OutputTextDoneEvent + | ReasoningDeltaEvent + | ReasoningDoneEvent + | FunctionCallArgumentsDeltaEvent + | FunctionCallArgumentsDoneEvent + | ResponseCompletedEvent + | ToolPreliminaryResultEvent; +``` + +### Event Type Reference + +| Event Type | Description | Payload | +|------------|-------------|---------| +| `response.created` | Response object initialized | `{ response: ResponseObject }` | +| `response.in_progress` | Generation has started | `{}` | +| `response.output_text.delta` | Text chunk received | `{ delta: string }` | +| `response.output_text.done` | Text generation complete | `{ text: string }` | +| `response.reasoning.delta` | Reasoning chunk (o1 models) | `{ delta: string }` | +| `response.reasoning.done` | Reasoning complete | `{ reasoning: string }` | +| `response.function_call_arguments.delta` | Tool argument chunk | `{ delta: string }` | +| `response.function_call_arguments.done` | Tool arguments complete | `{ arguments: string }` | +| `response.completed` | Full response complete | `{ response: ResponseObject }` | +| `tool.preliminary_result` | Generator tool progress | `{ toolCallId: string; result: unknown }` | + +### Event Interfaces + +```typescript +interface OutputTextDeltaEvent { + type: 'response.output_text.delta'; + delta: string; +} + +interface ReasoningDeltaEvent { + type: 'response.reasoning.delta'; + delta: string; +} + +interface FunctionCallArgumentsDeltaEvent { + type: 'response.function_call_arguments.delta'; + delta: string; +} + +interface ToolPreliminaryResultEvent { + type: 'tool.preliminary_result'; + toolCallId: string; + result: unknown; // Matches the tool's eventSchema +} + +interface ResponseCompletedEvent { + type: 'response.completed'; + response: OpenResponsesNonStreamingResponse; +} +``` + +### Tool Stream Events + +The `getToolStream()` method yields: + +```typescript +type ToolStreamEvent = + | { type: 'delta'; content: string } + | { type: 'preliminary_result'; toolCallId: string; result: unknown }; +``` + +### Cumulative Stream Types + +```typescript +type WithCompletion = T & { isComplete: boolean }; + +// StreamingFunctionCallItem — the function_call type in getItemsStream() +type StreamingFunctionCallItem = Omit & { + arguments?: Record | undefined; // Healed, always valid + rawArguments: string; // Raw accumulated string +}; + +// getItemsStream() yields StreamableOutputItem +type StreamableOutputItem = + | WithCompletion + | WithCompletion + | WithCompletion + | WithCompletion + | WithCompletion + | WithCompletion + | WithCompletion; + +// getNewMessagesStream() yields MessageStreamUpdate +type MessageStreamUpdate = + | WithCompletion + | OpenResponsesFunctionCallOutput + | ResponsesOutputItemFunctionCall; +``` + +### Message Stream Events + +The `getNewMessagesStream()` yields OpenResponses format updates: + +```typescript +type MessageStreamUpdate = + | ResponsesOutputMessage // Text/content snapshots + | OpenResponsesFunctionCallOutput; // Tool results +```