diff --git a/skills/create-agent/SKILL.md b/skills/create-agent/SKILL.md index 1a5286f..5a13638 100644 --- a/skills/create-agent/SKILL.md +++ b/skills/create-agent/SKILL.md @@ -1,67 +1,138 @@ --- name: create-agent -description: Bootstrap a modular AI agent with OpenRouter SDK, extensible hooks, and optional Ink TUI +description: Bootstrap a modular AI agent with OpenRouter SDK callModel() API, Zod tools, and Ink TUI metadata: - version: 0.0.0 + version: 0.4.0 homepage: https://openrouter.ai --- # Build a Modular AI Agent with OpenRouter -This skill helps you create a **modular AI agent** with: +This skill helps you create a **terminal-based agent**. Unlike simple chatbots, this agent can: -- **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) +- **Read & Write Files** - Modify your codebase directly +- **Edit Files** - Targeted find-and-replace edits +- **Execute Shell Commands** - Run tests, installs, and builds +- **Browse the Web** - Fetch documentation and search the internet +- **Look Good** - Includes a modern Ink-based Terminal UI (TUI) +- **OpenRouter SDK callModel()** - Automatic tool execution with items-based streaming ## Architecture ``` -┌─────────────────────────────────────────────────────┐ -│ Your Application │ -├─────────────────────────────────────────────────────┤ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Ink TUI │ │ HTTP API │ │ Discord │ │ -│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ -│ │ │ │ │ -│ └────────────────┼────────────────┘ │ -│ ▼ │ -│ ┌───────────────────────┐ │ -│ │ Agent Core │ │ -│ │ (hooks & lifecycle) │ │ -│ └───────────┬───────────┘ │ -│ ▼ │ -│ ┌───────────────────────┐ │ -│ │ OpenRouter SDK │ │ -│ └───────────────────────┘ │ -└─────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────┐ +│ cli.tsx (TUI) / headless.ts (CLI) │ +│ UI layer + user interaction │ +├─────────────────────────────────────────────────┤ +│ config.ts (Setup) │ +│ Env validation, shared agent instance │ +├─────────────────────────────────────────────────┤ +│ agent.ts (Core) │ +│ OpenRouter SDK callModel() + getItemsStream() │ +│ EventEmitter for UI hooks │ +├─────────────────────────────────────────────────┤ +│ tools.ts (Tools) │ +│ 7 Zod-schema tools with callbacks │ +│ list_files, read_file, edit_file, write_file, │ +│ run_command, fetch_web_page, web_search │ +└─────────────────────────────────────────────────┘ ``` +**How it works:** + +1. `config.ts` validates the API key and creates a shared agent instance +2. Agent calls `callModel()` with tools +3. SDK automatically validates args with Zod, executes tools, sends results back to model +4. SDK repeats until the model stops calling tools +5. `getItemsStream()` streams all items (messages, tool calls, results) to the TUI +6. Tool callbacks emit events for the UI's tool indicators + ## Prerequisites +> [!IMPORTANT] +> **Node.js LTS (latest stable even-numbered release) required.** +> Run `node -v` to check. If below the current LTS, upgrade via [nodejs.org](https://nodejs.org) or `nvm install --lts && nvm alias default lts/*`. +> All dependencies use unpinned ranges (`npm install` fetches latest compatible versions), so no version numbers in this skill will go stale. + Get an OpenRouter API key at: https://openrouter.ai/settings/keys -⚠️ **Security:** Never commit API keys. Use environment variables. +> [!CAUTION] +> **Dependency Safety Rules (MUST follow):** +> +> - Use the dependencies listed in the package.json below +> - Do NOT add `cheerio`, `undici`, or any package with native bindings — these frequently break across Node versions +> - For HTML parsing, use the built-in regex-based `htmlToText()` helper in tools.ts — no external HTML parser needed +> - After `npm install`, verify zero errors by running `npm start` before considering setup complete ## Project Setup ### Step 1: Initialize Project +Verify Node.js LTS is available, then scaffold: + ```bash +node -v # must be current LTS (even-numbered: 20, 22, 24, …) mkdir my-agent && cd my-agent npm init -y npm pkg set type="module" +mkdir src +echo -e 'node_modules/\ndist/\n.env\n.DS_Store' > .gitignore ``` ### Step 2: Install Dependencies +Install runtime dependencies (no version pinning — always fetches latest compatible): + +```bash +npm install @openrouter/sdk dotenv eventemitter3 glob ink react zod +``` + +| Package | Purpose | npm | +| ----------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `@openrouter/sdk` | LLM API client with streaming & tool-call support | [npmjs.com/package/@openrouter/sdk](https://www.npmjs.com/package/@openrouter/sdk) | +| `dotenv` | Loads `.env` vars into `process.env` | [npmjs.com/package/dotenv](https://www.npmjs.com/package/dotenv) | +| `eventemitter3` | Lightweight event bus for agent ↔ UI communication | [npmjs.com/package/eventemitter3](https://www.npmjs.com/package/eventemitter3) | +| `glob` | File-pattern matching for tool implementations | [npmjs.com/package/glob](https://www.npmjs.com/package/glob) | +| `ink` | React-based terminal UI framework | [npmjs.com/package/ink](https://www.npmjs.com/package/ink) | +| `react` | Required peer dependency for Ink | [npmjs.com/package/react](https://www.npmjs.com/package/react) | +| `zod` | Schema validation used for defining tool parameters | [npmjs.com/package/zod](https://www.npmjs.com/package/zod) | + +Install dev dependencies: + ```bash -npm install @openrouter/sdk zod eventemitter3 -npm install ink react # Optional: only for TUI -npm install -D typescript @types/react tsx +npm install -D @types/node @types/react tsx typescript +``` + +| Package | Purpose | npm | +| -------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `@types/node` | TypeScript types for Node.js APIs | [npmjs.com/package/@types/node](https://www.npmjs.com/package/@types/node) | +| `@types/react` | TypeScript types for React (needed by Ink) | [npmjs.com/package/@types/react](https://www.npmjs.com/package/@types/react) | +| `tsx` | TypeScript execution engine (runs `.ts`/`.tsx` directly) | [npmjs.com/package/tsx](https://www.npmjs.com/package/tsx) | +| `typescript` | TypeScript compiler | [npmjs.com/package/typescript](https://www.npmjs.com/package/typescript) | + +Then add the scripts to `package.json`: + +```bash +npm pkg set scripts.start="tsx src/cli.tsx" +npm pkg set scripts.start:headless="tsx src/headless.ts" +npm pkg set scripts.dev="tsx watch src/cli.tsx" +``` + +### Step 3: Create .env file + +Create a `.env` file in the project root with your API key and (optionally) a model: + +``` +OPENROUTER_API_KEY=your-key-here +MODEL=openrouter/auto ``` -### Step 3: Create tsconfig.json +`MODEL` is optional — it defaults to `openrouter/auto` (smart routing). Set it to any tool-capable model ID from [openrouter.ai/models](https://openrouter.ai/models). + +> [!CAUTION] +> Add `.env` to your `.gitignore` to avoid committing secrets! + +### Step 4: Create tsconfig.json ```json { @@ -79,177 +150,196 @@ npm install -D typescript @types/react tsx } ``` -### 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 +├── agent.ts # Core: callModel() with automatic tool execution & event emitter +├── config.ts # Shared setup: env validation, agent initialization +├── tools.ts # 7 Zod-schema tools with callbacks for UI events +├── cli.tsx # Ink TUI: ASCII banner, streaming, tool indicators +└── headless.ts # readline-based CLI mode ``` -## Step 1: Agent Core with Hooks +--- + +## Implementation -Create `src/agent.ts` - the standalone agent that can run anywhere: +### Step 5: Create src/agent.ts + +The agent uses `callModel()` which handles the entire tool execution loop internally. No manual while loop needed — the SDK validates tool args with Zod, executes tools, and re-queries the model automatically. We consume `getItemsStream()` to drive the full loop and extract text deltas for streaming. Tool callbacks emit events to the UI during execution. ```typescript -import { OpenRouter, tool, stepCountIs } from '@openrouter/sdk'; -import type { Tool, StopCondition, StreamableOutputItem } from '@openrouter/sdk'; +import { OpenRouter, stepCountIs } from '@openrouter/sdk'; import { EventEmitter } from 'eventemitter3'; -import { z } from 'zod'; +import { createTools } from './tools.js'; + +// ─── Types ─────────────────────────────────────────────────────────────────── -// Message types export interface Message { - role: 'user' | 'assistant' | 'system'; + role: 'user' | 'assistant'; content: string; } -// Agent events for hooks (items-based streaming model) +export interface ModelInfo { + id: string; + name: string; + contextLength: number | null; + promptPricing: string; + completionPricing: string; +} + 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; + 'tool:call': (name: string, args: Record) => void; + 'tool:result': (name: string, result: Record) => void; + 'model:changed': (modelId: string) => void; + error: (error: Error) => void; 'thinking:start': () => void; 'thinking:end': () => void; } - -// Agent configuration export interface AgentConfig { apiKey: string; + /** Model ID from OpenRouter (e.g. "anthropic/claude-sonnet-4"). Defaults to "openrouter/auto". */ model?: string; instructions?: string; - tools?: Tool[]; - maxSteps?: number; + maxToolRounds?: number; } -// The Agent class - runs independently of any UI +export const DEFAULT_MODEL = 'openrouter/auto'; + export class Agent extends EventEmitter { private client: OpenRouter; - private messages: Message[] = []; - private config: Required> & { apiKey: string }; + private history: Message[] = []; + private tools: ReturnType; + private config: { + model: string; + instructions: string; + maxToolRounds: number; + }; 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, + model: config.model ?? DEFAULT_MODEL, + instructions: config.instructions ?? 'You are a skilled AI assistant.', + maxToolRounds: config.maxToolRounds ?? 50, }; + // Tool callbacks are the single source of truth for tool events. + // No duplicate emissions from the stream — callbacks fire during execute(). + this.tools = createTools({ + onCall: (name, args) => + this.emit('tool:call', name, args as Record), + onResult: (name, result) => + this.emit('tool:result', name, result as Record), + }); + } + + /** The model ID currently in use (useful for displaying in UI). */ + get model(): string { + return this.config.model; } - // Get conversation history getMessages(): Message[] { - return [...this.messages]; + return [...this.history]; } - // Clear conversation clearHistory(): void { - this.messages = []; + this.history = []; } - // 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); + setModel(modelId: string): void { + this.config.model = modelId; + this.emit('model:changed', modelId); + } + + async listToolCapableModels(): Promise { + const response = await this.client.models.list({ + supportedParameters: 'tools', + }); + + const models = response.data.map((m) => ({ + id: m.id, + name: m.name, + contextLength: m.contextLength, + promptPricing: m.pricing.prompt, + completionPricing: m.pricing.completion, + })); + + // Provider priority order (earlier = higher priority) + const providerOrder = ['anthropic', 'openai', 'google', 'meta-llama', 'x-ai', 'deepseek', 'mistralai']; + + const getProviderPriority = (id: string): number => { + const provider = id.split('/')[0]; + const index = providerOrder.indexOf(provider); + return index === -1 ? providerOrder.length : index; + }; + + // Sort by: provider priority first, then alphabetically within each provider + return models.sort((a, b) => { + const priorityDiff = getProviderPriority(a.id) - getProviderPriority(b.id); + if (priorityDiff !== 0) return priorityDiff; + return a.name.localeCompare(b.name); + }); } - // 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.history.push(userMessage); this.emit('message:user', userMessage); this.emit('thinking:start'); + this.emit('stream:start'); try { + // callModel() handles the entire tool execution loop automatically. + // Tool callbacks emit tool:call / tool:result events during execution. + // We consume getItemsStream() to drive the full loop (including tool rounds) + // and extract text deltas from 'message' items for streaming to the UI. 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)], + input: this.history.map((m) => ({ + role: m.role, + content: m.content, + })), + tools: this.tools, + stopWhen: stepCountIs(this.config.maxToolRounds), }); - this.emit('stream:start'); + // getItemsStream() drives the full tool execution loop. + // We only extract text here — tool events come from callbacks. 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(); + if (item.type !== 'message') continue; + const parts = (item as { content?: Array<{ type: string; text?: string }> }).content; + if (!parts) continue; + const currentText = parts + .filter((p) => p.type === 'output_text' && p.text) + .map((p) => p.text!) + .join(''); + if (currentText.length <= fullText.length) continue; + const delta = currentText.slice(fullText.length); + fullText = currentText; + this.emit('stream:delta', delta, fullText); } this.emit('stream:end', fullText); - const assistantMessage: Message = { role: 'assistant', content: fullText }; - this.messages.push(assistantMessage); + const assistantMessage: Message = { + role: 'assistant', + content: fullText, + }; + this.history.push(assistantMessage); this.emit('message:assistant', assistantMessage); return fullText; @@ -261,209 +351,451 @@ export class Agent extends EventEmitter { 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 +--- + +### Step 6: Create src/tools.ts -Create `src/tools.ts`: +Tools use Zod schemas for type-safe parameters. The SDK automatically validates args against the schema before calling `execute`. Callbacks notify the UI about tool activity. ```typescript +import { z } from 'zod/v4'; 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', - }; - }, -}); +import * as fs from 'fs/promises'; +import { dirname } from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { glob } from 'glob'; -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 }; - }, -}); +const execAsync = promisify(exec); -export const defaultTools = [timeTool, calculatorTool]; -``` +const COMMAND_TIMEOUT_MS = 30_000; +const FETCH_TIMEOUT_MS = 15_000; -## Step 3: Headless Usage (No UI) +function toErrorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} -Create `src/headless.ts` - use the agent programmatically: +// Callbacks for tool event notifications +export interface ToolCallbacks { + onCall?: (name: string, args: unknown) => void; + onResult?: (name: string, result: unknown) => void; +} -```typescript -import { createAgent } from './agent.js'; -import { defaultTools } from './tools.js'; +// Lightweight HTML-to-text helper (no external dependencies) +function htmlToText(html: string): string { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); +} -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, - }); +function extractTitle(html: string): string { + const match = html.match(/]*>([\s\S]*?)<\/title>/i); + return match ? match[1].trim() : ''; +} - // 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)); +// Create all tools using the SDK's tool() helper +// Returns properly typed Tool[] for OpenRouter SDK callModel() +export function createTools(callbacks?: ToolCallbacks) { + const call = (name: string, args: unknown) => callbacks?.onCall?.(name, args); + const done = (name: string, result: T): T => { + callbacks?.onResult?.(name, result); + return result; + }; - // Interactive loop - const readline = await import('readline'); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); + return [ + tool({ + name: 'list_files', + description: 'List files in a directory to understand project structure', + inputSchema: z.object({ + path: z.string().default('.').describe('Directory to search'), + recursive: z.boolean().default(false).describe('List recursively'), + }), + execute: async (params) => { + call('list_files', params); + try { + const files = await glob(params.recursive ? '**/*' : '*', { + cwd: params.path, + nodir: false, + ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'], + }); + return done('list_files', { + files: files.slice(0, 100), + count: files.length, + }); + } catch (e) { + return done('list_files', { error: toErrorMessage(e) }); + } + }, + }), + + tool({ + name: 'read_file', + description: 'Read the contents of a specific file', + inputSchema: z.object({ + filepath: z.string().describe('Path to the file to read'), + }), + execute: async (params) => { + call('read_file', params); + try { + const content = await fs.readFile(params.filepath, 'utf-8'); + return done('read_file', { + filepath: params.filepath, + content, + length: content.length, + }); + } catch (e) { + return done('read_file', { + error: `Failed to read ${params.filepath}: ${toErrorMessage(e)}`, + }); + } + }, + }), + + tool({ + name: 'edit_file', + description: + 'Make targeted edits to a file by replacing specific text. Use this instead of write_file when you only need to change part of a file.', + inputSchema: z.object({ + filepath: z.string().describe('Path to the file to edit'), + old_string: z + .string() + .describe('The exact text to find and replace (must match exactly)'), + new_string: z.string().describe('The replacement text'), + replace_all: z + .boolean() + .default(false) + .describe('Replace all occurrences instead of just the first'), + }), + execute: async (params) => { + call('edit_file', params); + try { + const content = await fs.readFile(params.filepath, 'utf-8'); + if (!content.includes(params.old_string)) { + return done('edit_file', { + error: `old_string not found in ${params.filepath}. Make sure it matches exactly, including whitespace and indentation.`, + }); + } + const occurrences = content.split(params.old_string).length - 1; + if (occurrences > 1 && !params.replace_all) { + return done('edit_file', { + error: `old_string found ${occurrences} times in ${params.filepath}. Provide more context to make it unique, or set replace_all to true.`, + }); + } + const updated = params.replace_all + ? content.replaceAll(params.old_string, params.new_string) + : content.replace(params.old_string, params.new_string); + await fs.writeFile(params.filepath, updated, 'utf-8'); + return done('edit_file', { + success: true, + filepath: params.filepath, + replacements: params.replace_all ? occurrences : 1, + }); + } catch (e) { + return done('edit_file', { + error: `Failed to edit ${params.filepath}: ${toErrorMessage(e)}`, + }); + } + }, + }), + + tool({ + name: 'write_file', + description: 'Write content to a file (creates parent directories if needed). Overwrites existing content.', + inputSchema: z.object({ + filepath: z.string().describe('Path to the file to write'), + content: z.string().describe('Content to write'), + }), + execute: async (params) => { + call('write_file', params); + try { + await fs.mkdir(dirname(params.filepath), { recursive: true }); + await fs.writeFile(params.filepath, params.content, 'utf-8'); + return done('write_file', { + success: true, + filepath: params.filepath, + size: Buffer.byteLength(params.content, 'utf-8'), + }); + } catch (e) { + return done('write_file', { + error: `Failed to write ${params.filepath}: ${toErrorMessage(e)}`, + }); + } + }, + }), + + tool({ + name: 'run_command', + description: 'Execute a shell command (e.g., git, npm test, ls)', + inputSchema: z.object({ + command: z.string().describe('The shell command to execute'), + }), + execute: async (params) => { + call('run_command', params); + try { + const { stdout, stderr } = await execAsync(params.command, { + timeout: COMMAND_TIMEOUT_MS, + }); + return done('run_command', { + command: params.command, + stdout: stdout.trim().slice(0, 2000), + stderr: stderr.trim().slice(0, 500), + }); + } catch (e) { + const err = e as Error & { stdout?: string; stderr?: string }; + return done('run_command', { + error: 'Command failed', + message: err.message, + stdout: err.stdout?.slice(0, 1000), + stderr: err.stderr?.slice(0, 500), + }); + } + }, + }), + + tool({ + name: 'fetch_web_page', + description: 'Fetch text content from a URL (useful for reading docs)', + inputSchema: z.object({ + url: z.string().describe('URL to fetch'), + }), + execute: async (params) => { + call('fetch_web_page', params); + try { + const controller = new AbortController(); + setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const res = await fetch(params.url, { signal: controller.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const html = await res.text(); + const title = extractTitle(html); + const text = htmlToText(html).slice(0, 4000); + return done('fetch_web_page', { + url: params.url, + title, + content: text, + }); + } catch (e) { + return done('fetch_web_page', { + error: `Failed to fetch ${params.url}: ${toErrorMessage(e)}`, + }); + } + }, + }), + + tool({ + name: 'web_search', + description: + 'Search the internet for information using DuckDuckGo. Returns titles, URLs, and snippets.', + inputSchema: z.object({ + query: z.string().describe('The search query'), + num_results: z + .number() + .default(5) + .describe('Number of results (max 10)'), + }), + execute: async (params) => { + call('web_search', params); + const numResults = Math.min(params.num_results, 10); + try { + const searchUrl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(params.query)}`; + const controller = new AbortController(); + setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const res = await fetch(searchUrl, { + signal: controller.signal, + headers: { + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const html = await res.text(); + const results: Array<{ + title: string; + url: string; + snippet: string; + }> = []; + const resultBlocks = html.split(/class="result\s/); + for ( + let i = 1; + i < resultBlocks.length && results.length < numResults; + i++ + ) { + const block = resultBlocks[i]; + const titleMatch = block.match(/class="result__a"[^>]*>([^<]+)]*>([\s\S]*?)<\/a>/, + ); + const title = titleMatch ? titleMatch[1].trim() : ''; + let url = hrefMatch ? hrefMatch[1] : ''; + if (url.startsWith('//')) url = `https:${url}`; + const snippet = snippetMatch + ? htmlToText(snippetMatch[1]).slice(0, 200) + : ''; + if (title && url) results.push({ title, url, snippet }); + } + return done('web_search', { + query: params.query, + results, + count: results.length, + }); + } catch (e) { + return done('web_search', { + error: `Search failed: ${toErrorMessage(e)}`, + }); + } + }, + }), + ]; +} +``` - 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(); - }); - }; +### Step 7: Create src/config.ts - prompt(); +Shared setup module imported by both entry points. This is the single source of truth for API key validation, system instructions, and agent initialization — avoiding duplication between `cli.tsx` and `headless.ts`. + +```typescript +import 'dotenv/config'; +import { createAgent } from './agent.js'; + +if (!process.env.OPENROUTER_API_KEY) { + console.error('Error: OPENROUTER_API_KEY environment variable is required'); + console.error('Create a .env file with: OPENROUTER_API_KEY=your-key-here'); + process.exit(1); } -main().catch(console.error); +// SKILL INSTRUCTION: Replace with a system prompt tailored to the user's agent name and purpose. +export const agent = createAgent({ + apiKey: process.env.OPENROUTER_API_KEY, + model: process.env.MODEL, + instructions: `You are a helpful AI Agent with access to tools for working with files, running commands, writing code, and browsing the web. Use these tools when they're helpful for the task at hand, but you're not limited to just using tools - you can also engage in conversation, answer questions, provide advice, or help with any other request. + +When using tools, always provide context and synthesis - don't just call tools silently. Explain what you're doing and why.`, +}); ``` -Run headless: `OPENROUTER_API_KEY=sk-or-... npm run start:headless` +--- -## Step 4: Ink TUI (Optional Interface) +### Step 8: Create src/cli.tsx -Create `src/cli.tsx` - a beautiful terminal UI that uses the agent with items-based streaming: +A polished terminal interface with ASCII art banner, streaming text, and tool call indicators. -```tsx +````typescript 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} - - ); +import { render, Box, Text, useInput, useApp, useStdout } from 'ink'; +import { DEFAULT_MODEL, type ModelInfo } from './agent.js'; +import { agent } from './config.js'; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Theme — centralised so every muted/secondary colour is easy to tune +// ═══════════════════════════════════════════════════════════════════════════════ +const THEME = { + muted: '#999999', // secondary text, separators, labels + mutedDim: '#777777' // even less prominent (tool args, pagination) +} as const; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Simple markdown cleaner for terminal display +// ═══════════════════════════════════════════════════════════════════════════════ +function formatMarkdown(text: string): string { + return text + .replace(/^### (.+)$/gm, '\n░ $1') + .replace(/^## (.+)$/gm, '\n▓ $1') + .replace(/^# (.+)$/gm, '\n█ $1 █') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/_([^_]+)_/g, '$1') + .replace(/^[\s]*[-*]\s+/gm, ' • ') + .replace(/```[\w]*\n?([\s\S]*?)```/g, '\n$1\n') + .replace(/`([^`]+)`/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/\n{3,}/g, '\n\n') + .trim(); } -// 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' && } +// ═══════════════════════════════════════════════════════════════════════════════ +// ASCII Banner +// ═══════════════════════════════════════════════════════════════════════════════ +// SKILL INSTRUCTION: Ask the user for an ASCII art banner for their agent. +// If they provide one, paste it into the template string below. +// If they don't have one, generate one with: npx -y figlet -f "ANSI Shadow" "AGENT NAME" +// Then paste the output directly into the template literal below (no trimStart). +// The figlet output includes leading spaces for alignment — preserve them exactly. +// If figlet is unavailable, use a simple text fallback: +// const BANNER = `◆ MY AGENT`; +const BANNER = ``; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════════════════════════════ +interface ToolCallInfo { name: string; status: 'running' | 'complete'; args?: string; } +interface DisplayMessage { role: 'user' | 'assistant'; content: string; timestamp: Date; } + +// ═══════════════════════════════════════════════════════════════════════════════ +// Components +// ═══════════════════════════════════════════════════════════════════════════════ +// SKILL INSTRUCTION: Replace "MY AGENT" with the user's agent name in Header and MessageDisplay. +function Header({ currentModel }: { currentModel: string }) { + const { stdout } = useStdout(); + const width = stdout?.columns ?? 100; + const bannerWidth = BANNER.split('\n').reduce((max, line) => Math.max(max, line.length), 0); + + if (width >= bannerWidth + 2) { + return ( + + {BANNER} + + {'━'.repeat(Math.min(width - 2, bannerWidth))} - ); - } - 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} + + Model: + {currentModel} + + Type + /model + to switch + + Press + ESC + to exit - ); - } - default: - return null; + + ); } + return ( + + ◆ MY AGENT + + Model: + {currentModel} + + {'─'.repeat(Math.min(width - 2, 40))} + + ); } -function InputField({ - value, - onChange, - onSubmit, - disabled, -}: { - value: string; - onChange: (v: string) => void; - onSubmit: () => void; - disabled: boolean; +function InputBox({ value, onChange, onSubmit, disabled }: { + value: string; onChange: (v: string) => void; onSubmit: () => void; disabled: boolean; }) { useInput((input, key) => { if (disabled) return; @@ -471,382 +803,352 @@ function InputField({ else if (key.backspace || key.delete) onChange(value.slice(0, -1)); else if (input && !key.ctrl && !key.meta) onChange(value + input); }); + return ( + + + + {value} + {!disabled && } + {disabled && thinking...} + + + ); +} +function ToolCallDisplay({ tools }: { tools: ToolCallInfo[] }) { + if (!tools.length) return null; return ( - - {'> '} - {value} - {disabled ? ' ···' : '█'} + + {tools.map((tc, i) => ( + + + {tc.status === 'complete' ? '✓' : '⚡'} + + {tc.name} + {tc.args && {tc.args.slice(0, 40)}...} + + ))} ); } -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()); +function MessageDisplay({ msg }: { msg: DisplayMessage }) { + const isUser = msg.role === 'user'; + const time = msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const displayContent = isUser ? msg.content : formatMarkdown(msg.content); + return ( + + + {isUser ? '● You' : '◆ MY AGENT'} + {time} + + {displayContent} + + ); +} + +function StatusBar({ status }: { status: string }) { + return {status}; +} + +function ModelPicker({ models, currentModel, loading, onSelect, onCancel }: { + models: ModelInfo[] | null; + currentModel: string; + loading: boolean; + onSelect: (modelId: string) => void; + onCancel: () => void; +}) { + const [index, setIndex] = useState(0); + const items: ModelInfo[] = models + ? [{ id: DEFAULT_MODEL, name: 'Default (openrouter/auto)', contextLength: null, promptPricing: '0', completionPricing: '0' }, ...models] + : []; useInput((_, key) => { - if (key.escape) exit(); + if (loading) return; + if (key.escape) { onCancel(); return; } + if (key.return && items.length > 0) { onSelect(items[index].id); return; } + if (key.upArrow) setIndex(i => Math.max(0, i - 1)); + if (key.downArrow) setIndex(i => Math.min(items.length - 1, i + 1)); }); - // Subscribe to agent events using items-based streaming - useEffect(() => { - const onThinkingStart = () => { - setIsLoading(true); - setItems(new Map()); // Clear items for new response - }; + if (loading) { + return ( + + Fetching tool-capable models... + + ); + } - // Items-based streaming: replace items by ID, don't accumulate - const onItemUpdate = (item: StreamableOutputItem) => { - setItems((prev) => new Map(prev).set(item.id, item)); - }; + const WINDOW = 15; + const start = Math.max(0, Math.min(index - Math.floor(WINDOW / 2), items.length - WINDOW)); + const visible = items.slice(start, start + WINDOW); - const onMessageAssistant = () => { - setMessages(agent.getMessages()); - setItems(new Map()); // Clear streaming items - setIsLoading(false); - }; + return ( + + Select Model (↑↓ + Enter, ESC to cancel) + + {visible.map((m, i) => { + const realIndex = start + i; + const isHighlighted = realIndex === index; + const isActive = m.id === currentModel; + const ctx = m.contextLength ? ` (${(m.contextLength / 1000).toFixed(0)}k)` : ''; + return ( + + + {isHighlighted ? '❯ ' : ' '}{isActive ? '✓ ' : ' '}{m.name}{ctx} + + + ); + })} + + {items.length > WINDOW && ( + ({index + 1}/{items.length}) + )} + + ); +} - const onError = (err: Error) => { - setIsLoading(false); - }; +// ═══════════════════════════════════════════════════════════════════════════════ +// Main App +// ═══════════════════════════════════════════════════════════════════════════════ +function App() { + const { exit } = useApp(); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [toolCalls, setToolCalls] = useState([]); + const [streamingText, setStreamingText] = useState(''); + const [error, setError] = useState(null); + const [status, setStatus] = useState('Ready'); + const [currentModel, setCurrentModel] = useState(agent.model); + const [showModelPicker, setShowModelPicker] = useState(false); + const [models, setModels] = useState(null); + const [modelLoading, setModelLoading] = useState(false); - agent.on('thinking:start', onThinkingStart); - agent.on('item:update', onItemUpdate); - agent.on('message:assistant', onMessageAssistant); - agent.on('error', onError); + useInput((_, key) => { if (key.escape && !showModelPicker) exit(); }); + useEffect(() => { + const onStart = () => { setIsLoading(true); setToolCalls([]); setStreamingText(''); setError(null); setStatus('Processing...'); }; + const onDelta = (_d: string, acc: string) => { setStreamingText(acc); setStatus('Streaming...'); }; + const onToolCall = (name: string, args: unknown) => { + setToolCalls(prev => [...prev, { name, status: 'running', args: JSON.stringify(args) }]); + setStatus(`Running ${name}...`); + }; + const onToolResult = () => setToolCalls(prev => prev.map(tc => tc.status === 'running' ? { ...tc, status: 'complete' as const } : tc)); + const onDone = () => { + setMessages(agent.getMessages().map(m => ({ role: m.role, content: m.content, timestamp: new Date() }))); + setToolCalls([]); setStreamingText(''); setIsLoading(false); setStatus('Ready'); + }; + const onModelChanged = (modelId: string) => setCurrentModel(modelId); + + agent.on('thinking:start', onStart); + agent.on('stream:delta', onDelta); + agent.on('tool:call', onToolCall); + agent.on('tool:result', onToolResult); + agent.on('message:assistant', onDone); + agent.on('model:changed', onModelChanged); return () => { - agent.off('thinking:start', onThinkingStart); - agent.off('item:update', onItemUpdate); - agent.off('message:assistant', onMessageAssistant); - agent.off('error', onError); + agent.off('thinking:start', onStart); + agent.off('stream:delta', onDelta); + agent.off('tool:call', onToolCall); + agent.off('tool:result', onToolResult); + agent.off('message:assistant', onDone); + agent.off('model:changed', onModelChanged); }; }, []); - const sendMessage = useCallback(async () => { + const openModelPicker = useCallback(async () => { + setShowModelPicker(true); + if (!models) { + setModelLoading(true); + try { + const list = await agent.listToolCapableModels(); + setModels(list); + } catch (e) { + setError(`Failed to fetch models: ${e instanceof Error ? e.message : String(e)}`); + setShowModelPicker(false); + } finally { + setModelLoading(false); + } + } + }, [models]); + + const send = 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]); + if (text === '/model') { + openModelPicker(); + return; + } + setMessages(prev => [...prev, { role: 'user', content: text, timestamp: new Date() }]); + try { + await agent.send(text); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setIsLoading(false); + } + }, [input, isLoading, openModelPicker]); return ( - - - 🤖 OpenRouter Agent - (Esc to exit) + +
+ + {messages.map((msg, i) => )} + + {streamingText && isLoading && ( + + {/* SKILL INSTRUCTION: Replace "MY AGENT" with the user's agent name */} + ◆ MY AGENT (streaming...) + {streamingText} + + )} + {isLoading && !streamingText && !toolCalls.length && Thinking...} + {error && ✖ Error: {error}} - - - {/* Render completed messages */} - {messages.map((msg, i) => ( - - ))} - - {/* Render streaming items by type (items-based pattern) */} - {Array.from(items.values()).map((item) => ( - - ))} - - - - { agent.setModel(id); setShowModelPicker(false); }} + onCancel={() => setShowModelPicker(false)} /> - + ) : ( + <> + + + + )} ); } 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); - } +### Step 9: Create src/headless.ts - const response = await agent.sendSync(message); - res.json({ response, history: agent.getMessages() }); -}); - -app.listen(3000); -``` - -### Use with Discord +Useful for CI/CD pipelines or API integration. ```typescript -import { Client, GatewayIntentBits } from 'discord.js'; -import { createAgent } from './agent.js'; - -const discord = new Client({ - intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], -}); +import { DEFAULT_MODEL, type ModelInfo } from './agent.js'; +import { agent } from './config.js'; +import * as readline from 'readline'; -const agents = new Map(); +let cachedModels: ModelInfo[] | null = null; -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); +async function handleModelCommand(rl: readline.Interface): Promise { + console.log('\nFetching tool-capable models...'); + if (!cachedModels) { + cachedModels = await agent.listToolCapableModels(); } - 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 }; + console.log(`\n [0] Default (${DEFAULT_MODEL})`); + cachedModels.forEach((m, i) => { + const ctx = m.contextLength + ? `${(m.contextLength / 1000).toFixed(0)}k ctx` + : ''; + console.log(` [${i + 1}] ${m.name} (${m.id})${ctx ? ` — ${ctx}` : ''}`); + }); + console.log(`\nCurrent: ${agent.model}`); + + return new Promise((resolve) => { + rl.question('\nSelect model number (or Enter to cancel): ', (answer) => { + const trimmed = answer.trim(); + if (trimmed === '') { + console.log('Cancelled.\n'); + resolve(); + return; + } + const num = parseInt(trimmed, 10); + if (isNaN(num) || num < 0 || !cachedModels || num > cachedModels.length) { + console.log('Invalid selection.\n'); + resolve(); + return; + } + if (num === 0) { + agent.setModel(DEFAULT_MODEL); + console.log(`Switched to: Default (${DEFAULT_MODEL})\n`); + } else { + const selected = cachedModels[num - 1]; + agent.setModel(selected.id); + console.log(`Switched to: ${selected.name} (${selected.id})\n`); + } + resolve(); + }); + }); } -async function fetchModels(): Promise { - const res = await fetch('https://openrouter.ai/api/v1/models'); - const data = await res.json(); - return data.data; -} +async function main() { + agent.on('thinking:start', () => console.log('\n🤔 Thinking...')); + agent.on('tool:call', (name, args) => + console.log(`🔧 Using ${name}:`, JSON.stringify(args)), + ); + agent.on('tool:result', (name) => console.log(` ✅ Result from ${name}`)); + agent.on('stream:delta', (delta) => process.stdout.write(delta)); + agent.on('stream:end', () => console.log('\n')); -// 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; + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, }); -} - -// Example: Get latest Claude models -const claudeModels = await findModels({ author: 'anthropic' }); -console.log(claudeModels.map((m) => m.id)); + console.log( + // SKILL INSTRUCTION: Replace with the user's agent name + '🤖 AI Agent (Headless Mode)\nType your message (Ctrl+C to exit):\nCommands: /model\n', + ); -// Example: Get models with 100k+ context -const longContextModels = await findModels({ minContext: 100000 }); + const prompt = () => { + rl.question('You: ', async (input) => { + if (!input.trim()) { + prompt(); + return; + } + if (input.trim() === '/model') { + try { + await handleModelCommand(rl); + } catch (e) { + console.error( + 'Failed to list models:', + e instanceof Error ? e.message : String(e), + ); + } + prompt(); + return; + } + try { + await agent.send(input); + } catch (e) { + console.error('Error:', e instanceof Error ? e.message : String(e)); + } + prompt(); + }); + }; + prompt(); +} -// Example: Get cheap models -const cheapModels = await findModels({ maxPromptPrice: 0.0005 }); +main().catch(console.error); ``` -### 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.', -}); +## Running the Agent + +```bash +npm start # TUI mode +npm run start:headless # Headless mode ``` -### Using openrouter/auto +### Choosing a Model -For simplicity, use `openrouter/auto` which automatically selects the best -available model for your request: +By default the agent uses `openrouter/auto` (smart routing). Override via the `MODEL` environment variable: -```typescript -const agent = createAgent({ - apiKey: process.env.OPENROUTER_API_KEY!, - model: 'openrouter/auto', // Auto-selects best model -}); +```bash +MODEL=anthropic/claude-sonnet-4 npm start ``` -### Models API Reference - -- **Endpoint**: `GET https://openrouter.ai/api/v1/models` -- **Response**: `{ data: OpenRouterModel[] }` -- **Browse models**: https://openrouter.ai/models - -## Resources +Or set it in `.env`. Browse available models at [openrouter.ai/models](https://openrouter.ai/models). -- 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 +Use the `/model` command at runtime to switch between tool-capable models.