From e25f917b9a2df92c151b4400ae24d3a676956460 Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Tue, 7 Apr 2026 08:08:01 -0400 Subject: [PATCH] chore(skills): add create-agent and typescript-sdk skills --- .claude/skills/create-agent/.openskills.json | 6 + .claude/skills/create-agent/SKILL.md | 852 +++++++++++ .claude/skills/create-agent/metadata.json | 13 + .../skills/typescript-sdk/.openskills.json | 6 + .claude/skills/typescript-sdk/SKILL.md | 1249 +++++++++++++++++ .claude/skills/typescript-sdk/metadata.json | 13 + 6 files changed, 2139 insertions(+) create mode 100644 .claude/skills/create-agent/.openskills.json create mode 100644 .claude/skills/create-agent/SKILL.md create mode 100644 .claude/skills/create-agent/metadata.json create mode 100644 .claude/skills/typescript-sdk/.openskills.json create mode 100644 .claude/skills/typescript-sdk/SKILL.md create mode 100644 .claude/skills/typescript-sdk/metadata.json diff --git a/.claude/skills/create-agent/.openskills.json b/.claude/skills/create-agent/.openskills.json new file mode 100644 index 0000000..a462565 --- /dev/null +++ b/.claude/skills/create-agent/.openskills.json @@ -0,0 +1,6 @@ +{ + "source": "/tmp/skill-selector-curated-812268656", + "sourceType": "local", + "localPath": "/tmp/skill-selector-curated-812268656/create-agent", + "installedAt": "2026-04-07T03:45:37.970Z" +} diff --git a/.claude/skills/create-agent/SKILL.md b/.claude/skills/create-agent/SKILL.md new file mode 100644 index 0000000..1a5286f --- /dev/null +++ b/.claude/skills/create-agent/SKILL.md @@ -0,0 +1,852 @@ +--- +name: create-agent +description: Bootstrap a modular AI agent with OpenRouter SDK, extensible hooks, and optional Ink TUI +metadata: + version: 0.0.0 + homepage: https://openrouter.ai +--- + +# Build a Modular AI Agent with OpenRouter + +This skill helps you create a **modular AI agent** with: + +- **Standalone Agent Core** - Runs independently, extensible via hooks +- **OpenRouter SDK** - Unified access to 300+ language models +- **Optional Ink TUI** - Beautiful terminal UI (separate from agent logic) + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Your Application │ +├─────────────────────────────────────────────────────┤ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Ink TUI │ │ HTTP API │ │ Discord │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ └────────────────┼────────────────┘ │ +│ ▼ │ +│ ┌───────────────────────┐ │ +│ │ Agent Core │ │ +│ │ (hooks & lifecycle) │ │ +│ └───────────┬───────────┘ │ +│ ▼ │ +│ ┌───────────────────────┐ │ +│ │ OpenRouter SDK │ │ +│ └───────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +## Prerequisites + +Get an OpenRouter API key at: https://openrouter.ai/settings/keys + +⚠️ **Security:** Never commit API keys. Use environment variables. + +## Project Setup + +### Step 1: Initialize Project + +```bash +mkdir my-agent && cd my-agent +npm init -y +npm pkg set type="module" +``` + +### Step 2: Install Dependencies + +```bash +npm install @openrouter/sdk zod eventemitter3 +npm install ink react # Optional: only for TUI +npm install -D typescript @types/react tsx +``` + +### Step 3: Create tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["src"] +} +``` + +### Step 4: Add Scripts to package.json + +```json +{ + "scripts": { + "start": "tsx src/cli.tsx", + "start:headless": "tsx src/headless.ts", + "dev": "tsx watch src/cli.tsx" + } +} +``` + +## File Structure + +```bash +src/ +├── agent.ts # Standalone agent core with hooks +├── tools.ts # Tool definitions +├── cli.tsx # Ink TUI (optional interface) +└── headless.ts # Headless usage example +``` + +## Step 1: Agent Core with Hooks + +Create `src/agent.ts` - the standalone agent that can run anywhere: + +```typescript +import { OpenRouter, tool, stepCountIs } from '@openrouter/sdk'; +import type { Tool, StopCondition, StreamableOutputItem } from '@openrouter/sdk'; +import { EventEmitter } from 'eventemitter3'; +import { z } from 'zod'; + +// Message types +export interface Message { + role: 'user' | 'assistant' | 'system'; + content: string; +} + +// Agent events for hooks (items-based streaming model) +export interface AgentEvents { + 'message:user': (message: Message) => void; + 'message:assistant': (message: Message) => void; + 'item:update': (item: StreamableOutputItem) => void; // Items emitted with same ID, replace by ID + 'stream:start': () => void; + 'stream:delta': (delta: string, accumulated: string) => void; + 'stream:end': (fullText: string) => void; + 'tool:call': (name: string, args: unknown) => void; + 'tool:result': (name: string, result: unknown) => void; + 'reasoning:update': (text: string) => void; // Extended thinking content + 'error': (error: Error) => void; + 'thinking:start': () => void; + 'thinking:end': () => void; +} + + +// Agent configuration +export interface AgentConfig { + apiKey: string; + model?: string; + instructions?: string; + tools?: Tool[]; + maxSteps?: number; +} + +// The Agent class - runs independently of any UI +export class Agent extends EventEmitter { + private client: OpenRouter; + private messages: Message[] = []; + private config: Required> & { apiKey: string }; + + constructor(config: AgentConfig) { + super(); + this.client = new OpenRouter({ apiKey: config.apiKey }); + this.config = { + apiKey: config.apiKey, + model: config.model ?? 'openrouter/auto', + instructions: config.instructions ?? 'You are a helpful assistant.', + tools: config.tools ?? [], + maxSteps: config.maxSteps ?? 5, + }; + } + + // Get conversation history + getMessages(): Message[] { + return [...this.messages]; + } + + // Clear conversation + clearHistory(): void { + this.messages = []; + } + + // Add a system message + setInstructions(instructions: string): void { + this.config.instructions = instructions; + } + + // Register additional tools at runtime + addTool(newTool: Tool): void { + this.config.tools.push(newTool); + } + + // Send a message and get streaming response using items-based model + // Items are emitted multiple times with the same ID but progressively updated content + // Replace items by their ID rather than accumulating chunks + async send(content: string): Promise { + const userMessage: Message = { role: 'user', content }; + this.messages.push(userMessage); + this.emit('message:user', userMessage); + this.emit('thinking:start'); + + try { + const result = this.client.callModel({ + model: this.config.model, + instructions: this.config.instructions, + input: this.messages.map((m) => ({ role: m.role, content: m.content })), + tools: this.config.tools.length > 0 ? this.config.tools : undefined, + stopWhen: [stepCountIs(this.config.maxSteps)], + }); + + this.emit('stream:start'); + let fullText = ''; + + // Use getItemsStream() for items-based streaming (recommended) + // Each item emission is complete - replace by ID, don't accumulate + for await (const item of result.getItemsStream()) { + // Emit the item for UI state management (use Map keyed by item.id) + this.emit('item:update', item); + + switch (item.type) { + case 'message': + // Message items contain progressively updated content + const textContent = item.content?.find((c: { type: string }) => c.type === 'output_text'); + if (textContent && 'text' in textContent) { + const newText = textContent.text; + if (newText !== fullText) { + const delta = newText.slice(fullText.length); + fullText = newText; + this.emit('stream:delta', delta, fullText); + } + } + break; + case 'function_call': + // Function call arguments stream progressively + if (item.status === 'completed') { + this.emit('tool:call', item.name, JSON.parse(item.arguments || '{}')); + } + break; + case 'function_call_output': + this.emit('tool:result', item.callId, item.output); + break; + case 'reasoning': + // Extended thinking/reasoning content + const reasoningText = item.content?.find((c: { type: string }) => c.type === 'reasoning_text'); + if (reasoningText && 'text' in reasoningText) { + this.emit('reasoning:update', reasoningText.text); + } + break; + // Additional item types: web_search_call, file_search_call, image_generation_call + } + } + + // Get final text if streaming didn't capture it + if (!fullText) { + fullText = await result.getText(); + } + + this.emit('stream:end', fullText); + + const assistantMessage: Message = { role: 'assistant', content: fullText }; + this.messages.push(assistantMessage); + this.emit('message:assistant', assistantMessage); + + return fullText; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.emit('error', error); + throw error; + } finally { + this.emit('thinking:end'); + } + } + + // Send without streaming (simpler for programmatic use) + async sendSync(content: string): Promise { + const userMessage: Message = { role: 'user', content }; + this.messages.push(userMessage); + this.emit('message:user', userMessage); + + try { + const result = this.client.callModel({ + model: this.config.model, + instructions: this.config.instructions, + input: this.messages.map((m) => ({ role: m.role, content: m.content })), + tools: this.config.tools.length > 0 ? this.config.tools : undefined, + stopWhen: [stepCountIs(this.config.maxSteps)], + }); + + const fullText = await result.getText(); + const assistantMessage: Message = { role: 'assistant', content: fullText }; + this.messages.push(assistantMessage); + this.emit('message:assistant', assistantMessage); + + return fullText; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.emit('error', error); + throw error; + } + } +} + +// Factory function for easy creation +export function createAgent(config: AgentConfig): Agent { + return new Agent(config); +} +``` + +## Step 2: Define Tools + +Create `src/tools.ts`: + +```typescript +import { tool } from '@openrouter/sdk'; +import { z } from 'zod'; + +export const timeTool = tool({ + name: 'get_current_time', + description: 'Get the current date and time', + inputSchema: z.object({ + timezone: z.string().optional().describe('Timezone (e.g., "UTC", "America/New_York")'), + }), + execute: async ({ timezone }) => { + return { + time: new Date().toLocaleString('en-US', { timeZone: timezone || 'UTC' }), + timezone: timezone || 'UTC', + }; + }, +}); + +export const calculatorTool = tool({ + name: 'calculate', + description: 'Perform mathematical calculations', + inputSchema: z.object({ + expression: z.string().describe('Math expression (e.g., "2 + 2", "sqrt(16)")'), + }), + execute: async ({ expression }) => { + // Simple safe eval for basic math + const sanitized = expression.replace(/[^0-9+\-*/().\s]/g, ''); + const result = Function(`"use strict"; return (${sanitized})`)(); + return { expression, result }; + }, +}); + +export const defaultTools = [timeTool, calculatorTool]; +``` + +## Step 3: Headless Usage (No UI) + +Create `src/headless.ts` - use the agent programmatically: + +```typescript +import { createAgent } from './agent.js'; +import { defaultTools } from './tools.js'; + +async function main() { + const agent = createAgent({ + apiKey: process.env.OPENROUTER_API_KEY!, + model: 'openrouter/auto', + instructions: 'You are a helpful assistant with access to tools.', + tools: defaultTools, + }); + + // Hook into events + agent.on('thinking:start', () => console.log('\n🤔 Thinking...')); + agent.on('tool:call', (name, args) => console.log(`🔧 Using ${name}:`, args)); + agent.on('stream:delta', (delta) => process.stdout.write(delta)); + agent.on('stream:end', () => console.log('\n')); + agent.on('error', (err) => console.error('❌ Error:', err.message)); + + // Interactive loop + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + console.log('Agent ready. Type your message (Ctrl+C to exit):\n'); + + const prompt = () => { + rl.question('You: ', async (input) => { + if (!input.trim()) { + prompt(); + return; + } + await agent.send(input); + prompt(); + }); + }; + + prompt(); +} + +main().catch(console.error); +``` + +Run headless: `OPENROUTER_API_KEY=sk-or-... npm run start:headless` + +## Step 4: Ink TUI (Optional Interface) + +Create `src/cli.tsx` - a beautiful terminal UI that uses the agent with items-based streaming: + +```tsx +import React, { useState, useEffect, useCallback } from 'react'; +import { render, Box, Text, useInput, useApp } from 'ink'; +import type { StreamableOutputItem } from '@openrouter/sdk'; +import { createAgent, type Agent, type Message } from './agent.js'; +import { defaultTools } from './tools.js'; + +// Initialize agent (runs independently of UI) +const agent = createAgent({ + apiKey: process.env.OPENROUTER_API_KEY!, + model: 'openrouter/auto', + instructions: 'You are a helpful assistant. Be concise.', + tools: defaultTools, +}); + +function ChatMessage({ message }: { message: Message }) { + const isUser = message.role === 'user'; + return ( + + + {isUser ? '▶ You' : '◀ Assistant'} + + {message.content} + + ); +} + +// Render streaming items by type using the items-based pattern +function ItemRenderer({ item }: { item: StreamableOutputItem }) { + switch (item.type) { + case 'message': { + const textContent = item.content?.find((c: { type: string }) => c.type === 'output_text'); + const text = textContent && 'text' in textContent ? textContent.text : ''; + return ( + + ◀ Assistant + {text} + {item.status !== 'completed' && } + + ); + } + case 'function_call': + return ( + + {item.status === 'completed' ? ' ✓' : ' 🔧'} {item.name} + {item.status === 'in_progress' && '...'} + + ); + case 'reasoning': { + const reasoningText = item.content?.find((c: { type: string }) => c.type === 'reasoning_text'); + const text = reasoningText && 'text' in reasoningText ? reasoningText.text : ''; + return ( + + 💭 Thinking + {text} + + ); + } + default: + return null; + } +} + +function InputField({ + value, + onChange, + onSubmit, + disabled, +}: { + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + disabled: boolean; +}) { + useInput((input, key) => { + if (disabled) return; + if (key.return) onSubmit(); + else if (key.backspace || key.delete) onChange(value.slice(0, -1)); + else if (input && !key.ctrl && !key.meta) onChange(value + input); + }); + + return ( + + {'> '} + {value} + {disabled ? ' ···' : '█'} + + ); +} + +function App() { + const { exit } = useApp(); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + // Use Map keyed by item ID for efficient React state updates (items-based pattern) + const [items, setItems] = useState>(new Map()); + + useInput((_, key) => { + if (key.escape) exit(); + }); + + // Subscribe to agent events using items-based streaming + useEffect(() => { + const onThinkingStart = () => { + setIsLoading(true); + setItems(new Map()); // Clear items for new response + }; + + // Items-based streaming: replace items by ID, don't accumulate + const onItemUpdate = (item: StreamableOutputItem) => { + setItems((prev) => new Map(prev).set(item.id, item)); + }; + + const onMessageAssistant = () => { + setMessages(agent.getMessages()); + setItems(new Map()); // Clear streaming items + setIsLoading(false); + }; + + const onError = (err: Error) => { + setIsLoading(false); + }; + + agent.on('thinking:start', onThinkingStart); + agent.on('item:update', onItemUpdate); + agent.on('message:assistant', onMessageAssistant); + agent.on('error', onError); + + return () => { + agent.off('thinking:start', onThinkingStart); + agent.off('item:update', onItemUpdate); + agent.off('message:assistant', onMessageAssistant); + agent.off('error', onError); + }; + }, []); + + const sendMessage = useCallback(async () => { + if (!input.trim() || isLoading) return; + const text = input.trim(); + setInput(''); + setMessages((prev) => [...prev, { role: 'user', content: text }]); + await agent.send(text); + }, [input, isLoading]); + + return ( + + + 🤖 OpenRouter Agent + (Esc to exit) + + + + {/* Render completed messages */} + {messages.map((msg, i) => ( + + ))} + + {/* Render streaming items by type (items-based pattern) */} + {Array.from(items.values()).map((item) => ( + + ))} + + + + + + + ); +} + +render(); +``` + +Run TUI: `OPENROUTER_API_KEY=sk-or-... npm start` + +## Understanding Items-Based Streaming + +The OpenRouter SDK uses an **items-based streaming model** - a key paradigm where items are emitted multiple times with the same ID but progressively updated content. Instead of accumulating chunks, you **replace items by their ID**. + +### How It Works + +Each iteration of `getItemsStream()` yields a complete item with updated content: + +```typescript +// Iteration 1: Partial message +{ id: "msg_123", type: "message", content: [{ type: "output_text", text: "Hello" }] } + +// Iteration 2: Updated message (replace, don't append) +{ id: "msg_123", type: "message", content: [{ type: "output_text", text: "Hello world" }] } +``` + +For function calls, arguments stream progressively: + +```typescript +// Iteration 1: Partial arguments +{ id: "call_456", type: "function_call", name: "get_weather", arguments: "{\"q" } + +// Iteration 2: Complete arguments +{ id: "call_456", type: "function_call", name: "get_weather", arguments: "{\"query\": \"Paris\"}", status: "completed" } +``` + +### Why Items Are Better + +**Traditional (accumulation required):** +```typescript +let text = ''; +for await (const chunk of result.getTextStream()) { + text += chunk; // Manual accumulation + updateUI(text); +} +``` + +**Items (complete replacement):** +```typescript +const items = new Map(); +for await (const item of result.getItemsStream()) { + items.set(item.id, item); // Replace by ID + updateUI(items); +} +``` + +Benefits: +- **No manual chunk management** - each item is complete +- **Handles concurrent outputs** - function calls and messages can stream in parallel +- **Full TypeScript inference** for all item types +- **Natural Map-based state** works perfectly with React/UI frameworks + +## Extending the Agent + +### Add Custom Hooks + +```typescript +const agent = createAgent({ apiKey: '...' }); + +// Log all events +agent.on('message:user', (msg) => { + saveToDatabase('user', msg.content); +}); + +agent.on('message:assistant', (msg) => { + saveToDatabase('assistant', msg.content); + sendWebhook('new_message', msg); +}); + +agent.on('tool:call', (name, args) => { + analytics.track('tool_used', { name, args }); +}); + +agent.on('error', (err) => { + errorReporting.capture(err); +}); +``` + +### Use with HTTP Server + +```typescript +import express from 'express'; +import { createAgent } from './agent.js'; + +const app = express(); +app.use(express.json()); + +// One agent per session (store in memory or Redis) +const sessions = new Map(); + +app.post('/chat', async (req, res) => { + const { sessionId, message } = req.body; + + let agent = sessions.get(sessionId); + if (!agent) { + agent = createAgent({ apiKey: process.env.OPENROUTER_API_KEY! }); + sessions.set(sessionId, agent); + } + + const response = await agent.sendSync(message); + res.json({ response, history: agent.getMessages() }); +}); + +app.listen(3000); +``` + +### Use with Discord + +```typescript +import { Client, GatewayIntentBits } from 'discord.js'; +import { createAgent } from './agent.js'; + +const discord = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], +}); + +const agents = new Map(); + +discord.on('messageCreate', async (msg) => { + if (msg.author.bot) return; + + let agent = agents.get(msg.channelId); + if (!agent) { + agent = createAgent({ apiKey: process.env.OPENROUTER_API_KEY! }); + agents.set(msg.channelId, agent); + } + + const response = await agent.sendSync(msg.content); + await msg.reply(response); +}); + +discord.login(process.env.DISCORD_TOKEN); +``` + +## Agent API Reference + +### Constructor Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| apiKey | string | required | OpenRouter API key | +| model | string | 'openrouter/auto' | Model to use | +| instructions | string | 'You are a helpful assistant.' | System prompt | +| tools | Tool[] | [] | Available tools | +| maxSteps | number | 5 | Max agentic loop iterations | + +### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `send(content)` | Promise | Send message with streaming | +| `sendSync(content)` | Promise | Send message without streaming | +| `getMessages()` | Message[] | Get conversation history | +| `clearHistory()` | void | Clear conversation | +| `setInstructions(text)` | void | Update system prompt | +| `addTool(tool)` | void | Add tool at runtime | + +### Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `message:user` | Message | User message added | +| `message:assistant` | Message | Assistant response complete | +| `item:update` | StreamableOutputItem | Item emitted (replace by ID, don't accumulate) | +| `stream:start` | - | Streaming started | +| `stream:delta` | (delta, accumulated) | New text chunk | +| `stream:end` | fullText | Streaming complete | +| `tool:call` | (name, args) | Tool being called | +| `tool:result` | (name, result) | Tool returned result | +| `reasoning:update` | text | Extended thinking content | +| `thinking:start` | - | Agent processing | +| `thinking:end` | - | Agent done processing | +| `error` | Error | Error occurred | + +### Item Types (from getItemsStream) + +The SDK uses an items-based streaming model where items are emitted multiple times with the same ID but progressively updated content. Replace items by their ID rather than accumulating chunks. + +| Type | Purpose | +|------|---------| +| `message` | Assistant text responses | +| `function_call` | Tool invocations with streaming arguments | +| `function_call_output` | Results from executed tools | +| `reasoning` | Extended thinking content | +| `web_search_call` | Web search operations | +| `file_search_call` | File search operations | +| `image_generation_call` | Image generation operations | + +## Discovering Models + +**Do not hardcode model IDs** - they change frequently. Use the models API: + +### Fetch Available Models + +```typescript +interface OpenRouterModel { + id: string; + name: string; + description?: string; + context_length: number; + pricing: { prompt: string; completion: string }; + top_provider?: { is_moderated: boolean }; +} + +async function fetchModels(): Promise { + const res = await fetch('https://openrouter.ai/api/v1/models'); + const data = await res.json(); + return data.data; +} + +// Find models by criteria +async function findModels(filter: { + author?: string; // e.g., 'anthropic', 'openai', 'google' + minContext?: number; // e.g., 100000 for 100k context + maxPromptPrice?: number; // e.g., 0.001 for cheap models +}): Promise { + const models = await fetchModels(); + + return models.filter((m) => { + if (filter.author && !m.id.startsWith(filter.author + '/')) return false; + if (filter.minContext && m.context_length < filter.minContext) return false; + if (filter.maxPromptPrice) { + const price = parseFloat(m.pricing.prompt); + if (price > filter.maxPromptPrice) return false; + } + return true; + }); +} + +// Example: Get latest Claude models +const claudeModels = await findModels({ author: 'anthropic' }); +console.log(claudeModels.map((m) => m.id)); + +// Example: Get models with 100k+ context +const longContextModels = await findModels({ minContext: 100000 }); + +// Example: Get cheap models +const cheapModels = await findModels({ maxPromptPrice: 0.0005 }); +``` + +### Dynamic Model Selection in Agent + +```typescript +// Create agent with dynamic model selection +const models = await fetchModels(); +const bestModel = models.find((m) => m.id.includes('claude')) || models[0]; + +const agent = createAgent({ + apiKey: process.env.OPENROUTER_API_KEY!, + model: bestModel.id, // Use discovered model + instructions: 'You are a helpful assistant.', +}); +``` + +### Using openrouter/auto + +For simplicity, use `openrouter/auto` which automatically selects the best +available model for your request: + +```typescript +const agent = createAgent({ + apiKey: process.env.OPENROUTER_API_KEY!, + model: 'openrouter/auto', // Auto-selects best model +}); +``` + +### Models API Reference + +- **Endpoint**: `GET https://openrouter.ai/api/v1/models` +- **Response**: `{ data: OpenRouterModel[] }` +- **Browse models**: https://openrouter.ai/models + +## Resources + +- OpenRouter Docs: https://openrouter.ai/docs +- Models API: https://openrouter.ai/api/v1/models +- Ink Docs: https://github.com/vadimdemedes/ink +- Get API Key: https://openrouter.ai/settings/keys diff --git a/.claude/skills/create-agent/metadata.json b/.claude/skills/create-agent/metadata.json new file mode 100644 index 0000000..3def73a --- /dev/null +++ b/.claude/skills/create-agent/metadata.json @@ -0,0 +1,13 @@ +{ + "version": "1.1.0", + "organization": "OpenRouter Inc", + "date": "January 2026", + "abstract": "Complete guide for building modular AI agents with the OpenRouter TypeScript SDK. Features a standalone Agent class with EventEmitter-based hooks for extensibility, items-based streaming model for efficient UI state management, optional Ink TUI for interactive terminal interfaces, and examples for HTTP server and Discord integrations. Includes Zod-based tool definitions, streaming responses with support for reasoning/thinking items, multi-turn conversations, and dynamic model discovery via the OpenRouter Models API.", + "references": [ + "https://openrouter.ai/docs/sdks/typescript", + "https://openrouter.ai/docs/sdks/typescript/call-model/working-with-items", + "https://openrouter.ai/docs/api-reference", + "https://openrouter.ai/api/v1/models", + "https://github.com/vadimdemedes/ink" + ] +} diff --git a/.claude/skills/typescript-sdk/.openskills.json b/.claude/skills/typescript-sdk/.openskills.json new file mode 100644 index 0000000..45b71c5 --- /dev/null +++ b/.claude/skills/typescript-sdk/.openskills.json @@ -0,0 +1,6 @@ +{ + "source": "/tmp/skill-selector-curated-812268656", + "sourceType": "local", + "localPath": "/tmp/skill-selector-curated-812268656/typescript-sdk", + "installedAt": "2026-04-07T03:45:37.971Z" +} diff --git a/.claude/skills/typescript-sdk/SKILL.md b/.claude/skills/typescript-sdk/SKILL.md new file mode 100644 index 0000000..1389155 --- /dev/null +++ b/.claude/skills/typescript-sdk/SKILL.md @@ -0,0 +1,1249 @@ +--- +name: openrouter-typescript-sdk +description: Complete reference for integrating with 300+ AI models through the OpenRouter TypeScript SDK using the callModel pattern +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. + +--- + +## Installation + +```bash +npm install @openrouter/sdk +``` + +## Setup + +Get your API key from [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys), then initialize: + +```typescript +import OpenRouter from '@openrouter/sdk'; + +const client = new OpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY +}); +``` + +--- + +## 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 + +--- + +## Core Concepts: callModel + +The `callModel` function is the primary interface for text generation. It provides a unified, type-safe way to interact with any supported model. + +### Basic Usage + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Explain quantum computing in one sentence.', +}); + +const text = await result.getText(); +``` + +### Key Benefits + +- **Type-safe parameters** with full IDE autocomplete +- **Auto-generated from OpenAPI specs** - automatically updates with new models +- **Multiple consumption patterns** - text, streaming, structured data +- **Automatic tool execution** with multi-turn support + +--- + +## Input Formats + +The SDK accepts flexible input types for the `input` parameter: + +### String Input +A simple string becomes a user message: + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Hello, how are you?' +}); +``` + +### Message Arrays +For multi-turn conversations: + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: [ + { role: 'user', content: 'What is the capital of France?' }, + { role: 'assistant', content: 'The capital of France is Paris.' }, + { role: 'user', content: 'What is its population?' } + ] +}); +``` + +### Multimodal Content +Including images and text: + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: [ + { + role: 'user', + content: [ + { type: 'text', text: 'What is in this image?' }, + { type: 'image_url', image_url: { url: 'https://example.com/image.png' } } + ] + } + ] +}); +``` + +### System Instructions +Use the `instructions` parameter for system-level guidance: + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + instructions: 'You are a helpful coding assistant. Be concise.', + input: 'How do I reverse a string in Python?' +}); +``` + +--- + +## Response Methods + +The result object provides multiple methods for consuming the response: + +| Method | Purpose | +|--------|---------| +| `getText()` | Get complete text after all tools complete | +| `getResponse()` | Full response object with token usage | +| `getTextStream()` | Stream text deltas as they arrive | +| `getReasoningStream()` | Stream reasoning tokens (for o1/reasoning models) | +| `getToolCallsStream()` | Stream tool calls as they complete | + +### getText() + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Write a haiku about coding' +}); + +const text = await result.getText(); +console.log(text); +``` + +### getResponse() + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Hello!' +}); + +const response = await result.getResponse(); +console.log('Text:', response.text); +console.log('Token usage:', response.usage); +``` + +### getTextStream() + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Write a short story' +}); + +for await (const delta of result.getTextStream()) { + process.stdout.write(delta); +} +``` + +--- + +## Tool System + +Create strongly-typed tools using Zod schemas for automatic validation and type inference. + +### Defining Tools + +```typescript +import { tool } from '@openrouter/sdk'; +import { z } from 'zod'; + +const weatherTool = tool({ + name: 'get_weather', + description: 'Get current weather for a location', + inputSchema: z.object({ + location: z.string().describe('City name'), + units: z.enum(['celsius', 'fahrenheit']).optional().default('celsius') + }), + outputSchema: z.object({ + temperature: z.number(), + conditions: z.string(), + humidity: z.number() + }), + execute: async (params) => { + // Implement weather fetching logic + return { + temperature: 22, + conditions: 'Sunny', + humidity: 45 + }; + } +}); +``` + +### Using Tools with callModel + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'What is the weather in Paris?', + tools: [weatherTool] +}); + +const text = await result.getText(); +// The SDK automatically executes the tool and continues the conversation +``` + +### Tool Types + +#### Regular Tools +Standard execute functions that return a result: + +```typescript +const calculatorTool = tool({ + name: 'calculate', + description: 'Perform mathematical calculations', + inputSchema: z.object({ + expression: z.string() + }), + execute: async ({ expression }) => { + return { result: eval(expression) }; + } +}); +``` + +#### Generator Tools +Yield progress events using `eventSchema`: + +```typescript +const searchTool = tool({ + name: 'web_search', + description: 'Search the web', + inputSchema: z.object({ query: z.string() }), + eventSchema: z.object({ + type: z.literal('progress'), + message: z.string() + }), + outputSchema: z.object({ results: z.array(z.string()) }), + execute: async function* ({ query }) { + yield { type: 'progress', message: 'Searching...' }; + yield { type: 'progress', message: 'Processing results...' }; + return { results: ['Result 1', 'Result 2'] }; + } +}); +``` + +#### Manual Tools +Set `execute: false` to handle tool calls yourself: + +```typescript +const manualTool = tool({ + name: 'user_confirmation', + description: 'Request user confirmation', + inputSchema: z.object({ message: z.string() }), + execute: false +}); +``` + +--- + +## Multi-Turn Conversations with Stop Conditions + +Control automatic tool execution with stop conditions: + +```typescript +import { stepCountIs, maxCost, hasToolCall } from '@openrouter/sdk'; + +const result = client.callModel({ + model: 'openai/gpt-5.2', + input: 'Research this topic thoroughly', + tools: [searchTool, analyzeTool], + stopWhen: [ + stepCountIs(10), // Stop after 10 turns + maxCost(1.00), // Stop if cost exceeds $1.00 + hasToolCall('finish') // Stop when 'finish' tool is called + ] +}); +``` + +### Available Stop Conditions + +| Condition | Description | +|-----------|-------------| +| `stepCountIs(n)` | Stop after n turns | +| `maxCost(amount)` | Stop when cost exceeds amount | +| `hasToolCall(name)` | Stop when specific tool is called | + +### Custom Stop Conditions + +```typescript +const customStop = (context) => { + return context.messages.length > 20; +}; + +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Complex task', + tools: [myTool], + stopWhen: customStop +}); +``` + +--- + +## Dynamic Parameters + +Compute parameters based on conversation context: + +```typescript +const result = client.callModel({ + model: (ctx) => ctx.numberOfTurns > 3 ? 'openai/gpt-4' : 'openai/gpt-4o-mini', + temperature: (ctx) => ctx.numberOfTurns > 1 ? 0.3 : 0.7, + input: 'Hello!' +}); +``` + +### Context Object Properties + +| Property | Type | Description | +|----------|------|-------------| +| `numberOfTurns` | number | Current turn count | +| `messages` | array | All messages so far | +| `instructions` | string | Current system instructions | +| `totalCost` | number | Accumulated cost | + +--- + +## nextTurnParams: Context Injection + +Tools can modify parameters for subsequent turns, enabling skills and context-aware behavior: + +```typescript +const skillTool = tool({ + name: 'load_skill', + description: 'Load a specialized skill', + inputSchema: z.object({ + skill: z.string().describe('Name of the skill to load') + }), + nextTurnParams: { + instructions: (params, context) => { + const skillInstructions = loadSkillInstructions(params.skill); + return `${context.instructions}\n\n${skillInstructions}`; + } + }, + execute: async ({ skill }) => { + return { loaded: skill }; + } +}); +``` + +### Use Cases for nextTurnParams + +- **Skill Systems**: Dynamically load specialized capabilities +- **Context Accumulation**: Build up context over multiple turns +- **Mode Switching**: Change model behavior mid-conversation +- **Memory Injection**: Add retrieved context to instructions + +--- + +## Generation Parameters + +Control model behavior with these parameters: + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Write a creative story', + temperature: 0.7, // Creativity (0-2, default varies by model) + maxOutputTokens: 1000, // Maximum tokens to generate + topP: 0.9, // Nucleus sampling parameter + frequencyPenalty: 0.5, // Reduce repetition + presencePenalty: 0.5, // Encourage new topics + stop: ['\n\n'] // Stop sequences +}); +``` + +--- + +## Streaming + +All streaming methods support concurrent consumers from a single result object: + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Write a detailed explanation' +}); + +// Consumer 1: Stream text to console +const textPromise = (async () => { + for await (const delta of result.getTextStream()) { + process.stdout.write(delta); + } +})(); + +// Consumer 2: Get full response simultaneously +const responsePromise = result.getResponse(); + +// Both run concurrently +const [, response] = await Promise.all([textPromise, responsePromise]); +console.log('\n\nTotal tokens:', response.usage.totalTokens); +``` + +### Streaming Tool Calls + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Search for information about TypeScript', + tools: [searchTool] +}); + +for await (const toolCall of result.getToolCallsStream()) { + console.log(`Tool called: ${toolCall.name}`); + console.log(`Arguments: ${JSON.stringify(toolCall.arguments)}`); + console.log(`Result: ${JSON.stringify(toolCall.result)}`); +} +``` + +--- + +## Format Conversion + +Convert between ecosystem formats for interoperability: + +### OpenAI Format + +```typescript +import { fromChatMessages, toChatMessage } from '@openrouter/sdk'; + +// OpenAI messages → OpenRouter format +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: fromChatMessages(openaiMessages) +}); + +// Response → OpenAI chat message format +const response = await result.getResponse(); +const chatMsg = toChatMessage(response); +``` + +### Claude Format + +```typescript +import { fromClaudeMessages, toClaudeMessage } from '@openrouter/sdk'; + +// Claude messages → OpenRouter format +const result = client.callModel({ + model: 'anthropic/claude-3-opus', + input: fromClaudeMessages(claudeMessages) +}); + +// Response → Claude message format +const response = await result.getResponse(); +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 + +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; + } +} +``` + +### Message Stream Events + +The `getNewMessagesStream()` yields OpenResponses format updates: + +```typescript +type MessageStreamUpdate = + | ResponsesOutputMessage // Text/content updates + | OpenResponsesFunctionCallOutput; // Tool results +``` + +### Example: Tracking New Messages + +```typescript +const result = client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Research this topic', + tools: [searchTool] +}); + +const allMessages: MessageStreamUpdate[] = []; + +for await (const message of result.getNewMessagesStream()) { + allMessages.push(message); + + if (message.type === 'message') { + console.log('Assistant:', message.content); + } else if (message.type === 'function_call_output') { + console.log('Tool result:', message.output); + } +} +``` + +--- + +## API Reference + +### Client Methods + +Beyond `callModel`, the client provides access to other API endpoints: + +```typescript +const client = new OpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY +}); + +// List available models +const models = await client.models.list(); + +// Chat completions (alternative to callModel) +const completion = await client.chat.send({ + model: 'openai/gpt-5-nano', + messages: [{ role: 'user', content: 'Hello!' }] +}); + +// Legacy completions format +const legacyCompletion = await client.completions.generate({ + model: 'openai/gpt-5-nano', + prompt: 'Once upon a time' +}); + +// Usage analytics +const activity = await client.analytics.getUserActivity(); + +// Credit balance +const credits = await client.credits.getCredits(); + +// API key management +const keys = await client.apiKeys.list(); +``` + +--- + +## Error Handling + +The SDK provides specific error types with actionable messages: + +```typescript +try { + const result = await client.callModel({ + model: 'openai/gpt-5-nano', + input: 'Hello!' + }); + const text = await result.getText(); +} catch (error) { + if (error.statusCode === 401) { + console.error('Invalid API key - check your OPENROUTER_API_KEY'); + } else if (error.statusCode === 402) { + console.error('Insufficient credits - add credits at openrouter.ai'); + } else if (error.statusCode === 429) { + console.error('Rate limited - implement backoff retry'); + } else if (error.statusCode === 503) { + console.error('Model temporarily unavailable - try again or use fallback'); + } else { + console.error('Unexpected error:', error.message); + } +} +``` + +### Error Status Codes + +| Code | Meaning | Action | +|------|---------|--------| +| 400 | Bad request | Check request parameters | +| 401 | Unauthorized | Verify API key | +| 402 | Payment required | Add credits | +| 429 | Rate limited | Implement exponential backoff | +| 500 | Server error | Retry with backoff | +| 503 | Service unavailable | Try alternative model | + +--- + +## Complete Example: Agent with Tools + +```typescript +import OpenRouter, { tool, stepCountIs } from '@openrouter/sdk'; +import { z } from 'zod'; + +const client = new OpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY +}); + +// Define tools +const searchTool = tool({ + name: 'web_search', + description: 'Search the web for information', + inputSchema: z.object({ + query: z.string().describe('Search query') + }), + outputSchema: z.object({ + results: z.array(z.object({ + title: z.string(), + snippet: z.string(), + url: z.string() + })) + }), + execute: async ({ query }) => { + // Implement actual search + return { + results: [ + { title: 'Example', snippet: 'Example result', url: 'https://example.com' } + ] + }; + } +}); + +const finishTool = tool({ + name: 'finish', + description: 'Complete the task with final answer', + inputSchema: z.object({ + answer: z.string().describe('The final answer') + }), + execute: async ({ answer }) => ({ answer }) +}); + +// Run agent +async function runAgent(task: string) { + const result = client.callModel({ + model: 'openai/gpt-5-nano', + instructions: 'You are a helpful research assistant. Use web_search to find information, then use finish to provide your final answer.', + input: task, + tools: [searchTool, finishTool], + stopWhen: [ + stepCountIs(10), + hasToolCall('finish') + ] + }); + + // Stream progress + for await (const toolCall of result.getToolCallsStream()) { + console.log(`[${toolCall.name}] ${JSON.stringify(toolCall.arguments)}`); + } + + return await result.getText(); +} + +// Usage +const answer = await runAgent('What are the latest developments in quantum computing?'); +console.log('Final answer:', answer); +``` + +--- + +## Best Practices + +### 1. Prefer callModel Over Direct API Calls +The `callModel` pattern provides automatic tool execution, type safety, and multi-turn handling. + +### 2. Use Zod for Tool Schemas +Zod provides runtime validation and excellent TypeScript inference: + +```typescript +import { z } from 'zod'; + +const schema = z.object({ + name: z.string().min(1), + age: z.number().int().positive() +}); +``` + +### 3. Implement Stop Conditions +Always set reasonable limits to prevent runaway costs: + +```typescript +stopWhen: [stepCountIs(20), maxCost(5.00)] +``` + +### 4. Handle Errors Gracefully +Implement retry logic for transient failures: + +```typescript +async function callWithRetry(params, maxRetries = 3) { + for (let i = 0; i < maxRetries; i++) { + try { + return await client.callModel(params).getText(); + } catch (error) { + if (error.statusCode === 429 || error.statusCode >= 500) { + await sleep(Math.pow(2, i) * 1000); + continue; + } + throw error; + } + } +} +``` + +### 5. Use Streaming for Long Responses +Streaming provides better UX and allows early termination: + +```typescript +for await (const delta of result.getTextStream()) { + // Process incrementally +} +``` + +--- + +## Additional Resources + +- **API Keys**: [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) +- **Model List**: [openrouter.ai/models](https://openrouter.ai/models) +- **GitHub Issues**: [github.com/OpenRouterTeam/typescript-sdk/issues](https://github.com/OpenRouterTeam/typescript-sdk/issues) + +--- + +*SDK Status: Beta - Report issues on GitHub* diff --git a/.claude/skills/typescript-sdk/metadata.json b/.claude/skills/typescript-sdk/metadata.json new file mode 100644 index 0000000..2b007fa --- /dev/null +++ b/.claude/skills/typescript-sdk/metadata.json @@ -0,0 +1,13 @@ +{ + "version": "1.0.0", + "organization": "OpenRouter Inc", + "date": "January 2026", + "abstract": "Complete reference for the OpenRouter TypeScript SDK, enabling AI agents to integrate with 300+ AI models through a unified, type-safe interface. Covers the callModel pattern for text generation, streaming, and multi-turn conversations with automatic tool execution. Includes detailed TypeScript interfaces for Responses API message shapes, streaming event types, authentication methods (API key and OAuth PKCE flow), Zod-based tool definitions, stop conditions, and format conversion helpers. Designed for AI agent consumption with production-ready examples and best practices.", + "references": [ + "https://openrouter.ai/docs/sdks/typescript/call-model/overview", + "https://openrouter.ai/docs/sdks", + "https://openrouter.ai/docs/api/reference/overview", + "https://openrouter.ai/docs/sdks/typescript/api-reference/api-reference/oauth", + "https://openrouter.ai/docs/sdks/llms-full.txt" + ] +}