963 markdown files reformatted with the repository's pinned prettier so pnpm format:check covers the folded tree like every other repo file. The formatter's embedded-language pass also normalized code fences (TS semicolons, closed HTML tags in examples, lowercased CSS hex colors, one renumbered list that skipped an index). Alphanumeric token deltas vs the fold commit were audited file-by-file; all are formatter-equivalent markup normalizations plus the four sanitized skills.
864 lines
28 KiB
Markdown
864 lines
28 KiB
Markdown
---
|
|
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<z.ZodTypeAny, z.ZodTypeAny>[];
|
|
maxSteps?: number;
|
|
}
|
|
|
|
// The Agent class - runs independently of any UI
|
|
export class Agent extends EventEmitter<AgentEvents> {
|
|
private client: OpenRouter;
|
|
private messages: Message[] = [];
|
|
private config: Required<Omit<AgentConfig, 'apiKey'>> & { 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<z.ZodTypeAny, z.ZodTypeAny>): 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<string> {
|
|
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<string> {
|
|
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 (
|
|
<Box flexDirection="column" marginBottom={1}>
|
|
<Text bold color={isUser ? 'cyan' : 'green'}>
|
|
{isUser ? '▶ You' : '◀ Assistant'}
|
|
</Text>
|
|
<Text wrap="wrap">{message.content}</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<Box flexDirection="column" marginBottom={1}>
|
|
<Text bold color="green">
|
|
◀ Assistant
|
|
</Text>
|
|
<Text wrap="wrap">{text}</Text>
|
|
{item.status !== 'completed' && <Text color="gray">▌</Text>}
|
|
</Box>
|
|
);
|
|
}
|
|
case 'function_call':
|
|
return (
|
|
<Text color="yellow">
|
|
{item.status === 'completed' ? ' ✓' : ' 🔧'} {item.name}
|
|
{item.status === 'in_progress' && '...'}
|
|
</Text>
|
|
);
|
|
case 'reasoning': {
|
|
const reasoningText = item.content?.find(
|
|
(c: { type: string }) => c.type === 'reasoning_text',
|
|
);
|
|
const text = reasoningText && 'text' in reasoningText ? reasoningText.text : '';
|
|
return (
|
|
<Box flexDirection="column" marginBottom={1}>
|
|
<Text bold color="magenta">
|
|
💭 Thinking
|
|
</Text>
|
|
<Text wrap="wrap" color="gray">
|
|
{text}
|
|
</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
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 (
|
|
<Box>
|
|
<Text color="yellow">{'> '}</Text>
|
|
<Text>{value}</Text>
|
|
<Text color="gray">{disabled ? ' ···' : '█'}</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function App() {
|
|
const { exit } = useApp();
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
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<Map<string, StreamableOutputItem>>(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 (
|
|
<Box flexDirection="column" padding={1}>
|
|
<Box marginBottom={1}>
|
|
<Text bold color="magenta">
|
|
🤖 OpenRouter Agent
|
|
</Text>
|
|
<Text color="gray"> (Esc to exit)</Text>
|
|
</Box>
|
|
|
|
<Box flexDirection="column" marginBottom={1}>
|
|
{/* Render completed messages */}
|
|
{messages.map((msg, i) => (
|
|
<ChatMessage key={i} message={msg} />
|
|
))}
|
|
|
|
{/* Render streaming items by type (items-based pattern) */}
|
|
{Array.from(items.values()).map((item) => (
|
|
<ItemRenderer key={item.id} item={item} />
|
|
))}
|
|
</Box>
|
|
|
|
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
|
<InputField value={input} onChange={setInput} onSubmit={sendMessage} disabled={isLoading} />
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
render(<App />);
|
|
```
|
|
|
|
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<string, StreamableOutputItem>();
|
|
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<string, Agent>();
|
|
|
|
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<string, Agent>();
|
|
|
|
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<string> | Send message with streaming |
|
|
| `sendSync(content)` | Promise<string> | 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<OpenRouterModel[]> {
|
|
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<OpenRouterModel[]> {
|
|
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
|