Claude Agent Skill · by Openrouterteam

Create Agent

Install Create Agent skill for Claude Code from openrouterteam/agent-skills.

Install
Terminal · npx
$npx skills add https://github.com/openrouterteam/agent-skills --skill create-agent
Works with Paperclip

How Create Agent fits into a Paperclip company.

Create Agent drops into any Paperclip agent that handles this kind of work. Assign it to a specialist inside a pre-configured PaperclipOrg company and the skill becomes available on every heartbeat — no prompt engineering, no tool wiring.

S
SaaS FactoryPaired

Pre-configured AI company — 18 agents, 18 skills, one-time purchase.

$27$59
Explore pack
Source file
SKILL.md852 lines
Expand
---name: create-agentdescription: Bootstrap a modular AI agent with OpenRouter SDK, extensible hooks, and optional Ink TUImetadata:  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 ```bashmkdir my-agent && cd my-agentnpm init -ynpm pkg set type="module"``` ### Step 2: Install Dependencies ```bashnpm install @openrouter/sdk zod eventemitter3npm install ink react  # Optional: only for TUInpm 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 ```bashsrc/├── 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: ```typescriptimport { OpenRouter, tool, stepCountIs } from '@openrouter/sdk';import type { Tool, StopCondition, StreamableOutputItem } from '@openrouter/sdk';import { EventEmitter } from 'eventemitter3';import { z } from 'zod'; // Message typesexport 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 configurationexport interface AgentConfig {  apiKey: string;  model?: string;  instructions?: string;  tools?: Tool<z.ZodTypeAny, z.ZodTypeAny>[];  maxSteps?: number;} // The Agent class - runs independently of any UIexport 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 creationexport function createAgent(config: AgentConfig): Agent {  return new Agent(config);}``` ## Step 2: Define Tools Create `src/tools.ts`: ```typescriptimport { 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: ```typescriptimport { 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: ```tsximport 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 patternfunction 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):**```typescriptlet text = '';for await (const chunk of result.getTextStream()) {  text += chunk;  // Manual accumulation  updateUI(text);}``` **Items (complete replacement):**```typescriptconst 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 ```typescriptconst agent = createAgent({ apiKey: '...' }); // Log all eventsagent.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 ```typescriptimport 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 ```typescriptimport { 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 ```typescriptinterface 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 criteriaasync 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 modelsconst claudeModels = await findModels({ author: 'anthropic' });console.log(claudeModels.map((m) => m.id)); // Example: Get models with 100k+ contextconst longContextModels = await findModels({ minContext: 100000 }); // Example: Get cheap modelsconst cheapModels = await findModels({ maxPromptPrice: 0.0005 });``` ### Dynamic Model Selection in Agent ```typescript// Create agent with dynamic model selectionconst 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 bestavailable model for your request: ```typescriptconst 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