create-agent
OpenRouter SDKを使用してモジュール型AIエージェントをブートストラップし、拡張可能なフックをサポートします。
npx skills add openrouterteam/agent-skills --skill create-agentBefore / After 効果比較
1 组ゼロからAIエージェントアプリケーションを構築する場合、様々なSDKやコンポーネントを手動で統合する必要があり、開発期間が長くなります。開発者は技術選定やアーキテクチャ設計の課題に直面することがよくあります。
このスキルを活用することで、モジュール型AIエージェントの構築を迅速にガイドできます。OpenRouter SDKと拡張可能なフックを統合することで、開発プロセスが簡素化され、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
mkdir my-agent && cd my-agent
npm init -y
npm pkg set type="module"
Step 2: Install Dependencies
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
{
"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
{
"scripts": {
"start": "tsx src/cli.tsx",
"start:headless": "tsx src/headless.ts",
"dev": "tsx watch src/cli.tsx"
}
}
File Structure
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:
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:
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:
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:
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.
...
ユーザーレビュー (0)
レビューを書く
レビューなし
統計データ
ユーザー評価
この Skill を評価