mcpField
C
create-headless-agent

create-headless-agent

github.com/openrouterteamエージェントワークフロー309259 回インストール1 日前に更新
リポジトリ

create-headless-agent について

create-headless-agentは、OpenRouterと@openrouter/agentを使うTypeScript製ヘッドレスエージェントの雛形を作るスキルです。エントリーポイント、ツール、モジュールを選び、ターミナルUIのないプログラム構成を生成します。

主な機能

  1. 01エントリーポイントとツールの選択
  2. 02CLI、ライブラリ、HTTP、MCPサーバーの構成
  3. 03ファイル・シェルツールの生成
  4. 04セッション保存とリトライのモジュール
  5. 05OpenRouterサーバーツールの選択

活用シーン

  1. 01TypeScriptエージェントプロジェクトの開始
  2. 02既存エージェントへのツールやモジュールの追加
  3. 03CLI、HTTP、MCP経由でのエージェント公開
  4. 04ファイルやシェルを扱うエージェントの作成

MCP 対応

stdio経由でエージェントをMCPツールとして公開するMCPサーバーのエントリーポイントを生成できます。

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/openrouterteam/skills --skill create-headless-agent

インストール後、Claude Code で/create-headless-agentを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

metadata.json
README.md
references/
entry-points.md
modules.md
tools.md
sample/
.env.example
.gitignore
agent.config.json
bun.lock
package.json
tsconfig.json
sample/src/
agent.ts
cli.ts
config.ts
session.ts
sample/src/tools/
custom.ts
file-edit.ts
file-read.ts
file-write.ts
glob.ts
grep.ts
index.ts
list-dir.ts
shell.ts
sample/test/
agent.test.ts
output-schema.test.ts
retry.test.ts

詳細

ヘッドレスエージェントを作成する

OpenRouter を対象とするヘッドレスエージェントを TypeScript でスキャフォールディングします。生成されるプロジェクトは、内側のループ(モデル呼び出し、ツール実行、停止条件)に @openrouter/agent を使用し、設定、セッション管理、ツール定義、および 1 つ以上のエントリーポイント(CLI、HTTP サーバー、MCP サーバー、またはライブラリのインポート)を備えた、シンプルなプログラム用のシェルを提供します。ターミナル UI も readline も ANSI もなく、入力を受け取り、結果を返すだけです。

前提条件

  • Bun 1.1+
  • openrouter.ai/settings/keys で取得した OPENROUTER_API_KEY
  • SDK の完全なリファレンスについては、openrouter-typescript-sdk スキルを参照してください

判断ツリー

ユーザーの希望アクション
新しいヘッドレスエージェントを構築する以下のチェックリストを提示し、生成ワークフローに従います
既存のエージェントにツールを追加するreferences/tools.md を読み、ツールのチェックリストのみを提示します
モジュールを追加するreferences/modules.md を読み、モジュールを生成します
エントリーポイントを追加するreferences/entry-points.md を読み、生成します

インタラクティブ機能チェックリスト

複数選択可能なチェックリストとして提示します。ON と記された項目は、デフォルトで選択されています。

エントリーポイント(1 つ以上選択)
エントリーポイントデフォルト説明
CLION引数/stdin からエージェントへ、エージェントから stdout へ。NDJSON には --json を使用
ライブラリモジュールONimport { runAgent } from './agent'
HTTP サーバーOFFSSE ストリーミング対応の Bun.serve()
MCP サーバーOFFstdio 経由で MCP ツールとして公開
OpenRouter サーバーツール(サーバー側、実装不要)
ツール型文字列デフォルト
ウェブ検索openrouter:web_searchON
ウェブ取得openrouter:web_fetchON
日時openrouter:datetimeON
画像生成openrouter:image_generationOFF

サーバーツールは、ユーザー定義ツールとともに tools 配列に指定します。クライアント側のコードは不要です。OpenRouter が実行します。ドキュメント:openrouter.ai/docs/guides/features/server-tools。

ユーザー定義ツール(クライアント側、src/tools/ に生成)
ツールデフォルト説明
ファイル読み取りONオフセット/上限を指定してファイルを読み取ります
ファイル書き込みONファイルを書き込み/作成し、ディレクトリを自動作成します
ファイル編集ON差分を検証しながら検索と置換を行います
Glob/検索ONglob パターンでファイルを検索します
Grep/検索ON正規表現で内容を検索します
ディレクトリ一覧ONディレクトリの内容を一覧表示します
Shell/BashONタイムアウトと出力取得に対応してコマンドを実行します
カスタムツールテンプレートONドメイン固有のツール用の空のひな形
JS/TS REPLOFF永続的な Bun REPL
サブエージェントの起動OFF子エージェントにタスクを委任します
画像の表示OFFローカル画像を base64 として読み取ります
エージェントモジュール(アーキテクチャコンポーネント)
モジュールデフォルト説明
セッション永続化ONJSONL 会話ログ。無効にするには --no-session を指定します
バックオフ付き再試行ONagent.ts に組み込み済み
コンテキストの圧縮OFFコンテキストが長い場合に要約します
ツール結果の退避OFF大きすぎるツール出力をディスクに保存し、プレビューをコンテキストに残します
システムプロンプトの構成OFFコンテキストファイルから動的に指示を生成します
ツール承認フローOFFプログラムから承認/拒否します
構造化イベントログOFFstderr に JSON イベントを出力します
出力スキーマ検証OFF応答を制約する Zod スキーマ
Webhook 通知OFF完了時に POST します
CLI 出力モード(CLI エントリーポイントが ON の場合、1 つ選択)
モードデフォルト説明
テキストON最終応答のテキストを stdout に出力します
JSONOFFNDJSON イベントストリーム
QuietOFF終了コードのみ

生成ワークフロー

生成を始める前に、ユーザーにエージェントの名前を尋ねてください。この名前は次の用途に使用します。

  • package.json の "name" フィールド
  • "bin" コマンド(bun link により、グローバルに呼び出せる CLI になります)
  • プロジェクトディレクトリ名(新しいディレクトリを作成する場合)

質問の例: 「エージェントにどのような名前を付けますか?(短い kebab-case の名前。例:research-bot または docs-helper)」。回答が有効な npm パッケージ名(小文字、kebab-case、スペースなし)であることを確認してください。ユーザーに希望がない場合は、my-agent をデフォルトにします。以下のワークフローで <agent-name> と示されている箇所には、選択した名前を使用してください。

名前とチェックリストの選択内容を確認したら、次のワークフローに従ってください。

- [ ] Generate package.json with name=<agent-name> and bin={"<agent-name>": "src/cli.ts"}
- [ ] Generate tsconfig.json (Bun-native)
- [ ] Generate src/config.ts
- [ ] Generate src/tools/index.ts wiring selected tools
- [ ] Generate selected tool files in src/tools/ (specs in references/tools.md)
- [ ] Generate src/agent.ts (core runner)
- [ ] If Session Persistence ON: generate src/session.ts (spec in references/modules.md)
- [ ] Generate selected modules (specs in references/modules.md)
- [ ] Generate src/cli.ts entry point with shebang `#!/usr/bin/env bun` (spec in references/entry-points.md)
- [ ] If HTTP server selected: generate src/server.ts (spec in references/entry-points.md)
- [ ] If MCP server selected: generate src/mcp-server.ts (spec in references/entry-points.md)
- [ ] Generate .env.example
- [ ] Generate test/agent.test.ts
- [ ] Run `bun install` to fetch dependencies
- [ ] Verify: run `bunx tsc --noEmit`
- [ ] Run `bun link` inside the project to register <agent-name> globally
- [ ] Verify the command is on PATH: `command -v <agent-name>` should print a path. If it fails, tell the user to add Bun's bin dir to their shell rc:
      `export PATH="$HOME/.bun/bin:$PATH"` (for bash/zsh). `bun link` silently succeeds even when `~/.bun/bin` isn't on PATH, so without this check the user will be told the agent is globally available but `command not found` will greet them.
- [ ] Tell the user they can now invoke their agent from anywhere with `<agent-name> "<prompt>"`
- [ ] Optional: run `npx skills-ref validate .` to check SKILL.md frontmatter (if installed)

生成後、ユーザーは任意のディレクトリからエージェントを実行できます。

<agent-name> "What's in this repo?"
echo "Summarize README.md" | <agent-name>
<agent-name> --json "List all TODOs" | jq .

後でエージェントの名前を変更するには、package.json の name キーと bin キーを更新してから、bun unlink && bun link を実行します。


ツールのパターン

ユーザー定義のツールはすべて、@openrouter/agent/tool を使用する次のパターンに従います。以下に完全な例を示します。references/tools.md にある他のすべてのツールも同じ形式です。

import { tool } from '@openrouter/agent/tool';
import { z } from 'zod';

const DEFAULT_LINE_LIMIT = 2000;
const MAX_LINE_CHARS = 2000;

export const fileReadTool = tool({
  name: 'file_read',
  description:
    'Read the contents of a file. Output is capped at 2000 lines by default (use offset/limit to paginate) and any line longer than 2000 characters is truncated. When the response is truncated, the hint field tells you how to continue.',
  inputSchema: z.object({
    path: z.string().describe('Absolute path to the file'),
    offset: z.number().optional().describe('Start reading from this line (1-indexed)'),
    limit: z.number().optional().describe(`Maximum lines to return (default ${DEFAULT_LINE_LIMIT})`),
  }),
  execute: async ({ path, offset, limit }) => {
    try {
      const content = await Bun.file(path).text();
      const lines = content.split('\n');
      const start = offset ? offset - 1 : 0;
      const end = Math.min(start + (limit ?? DEFAULT_LINE_LIMIT), lines.length);
      let longLines = 0;
      const slice = lines.slice(start, end).map((line) => {
        if (line.length <= MAX_LINE_CHARS) return line;
        longLines++;
        return line.slice(0, MAX_LINE_CHARS) + `… [line truncated, ${line.length - MAX_LINE_CHARS} chars dropped]`;
      });
      const tailTruncated = end < lines.length;
      const truncated = tailTruncated || longLines > 0;
      const hintParts: string[] = [`Showing lines ${start + 1}-${end} of ${lines.length}.`];
      if (tailTruncated) hintParts.push(`Use offset=${end + 1} to continue.`);
      if (longLines > 0) hintParts.push(`${longLines} line(s) exceeded ${MAX_LINE_CHARS} chars and were per-line truncated; use grep to fetch content from those lines.`);
      return {
        content: slice.join('\n'),
        totalLines: lines.length,
        ...(truncated && {
          truncated: true,
          ...(tailTruncated && { nextOffset: end + 1 }),
          hint: hintParts.join(' '),
        }),
      };
    } catch (err: any) {
      if (err.code === 'ENOENT') return { error: `File not found: ${path}` };
      if (err.code === 'EACCES') return { error: `Permission denied: ${path}` };
      return { error: err.message };
    }
  },
});

他のすべてのツールの仕様については、references/tools.md を参照してください。


コアファイル

これらのファイルは常に生成されます。エージェントはチェックリストの選択内容に応じて、これらのファイルを調整します。

package.json

プロジェクトを初期化し、依存関係をインストールします。<agent-name> はユーザーが選択した名前に置き換えてください。

bun init -y
# Then edit package.json:
{
  "name": "<agent-name>",
  "type": "module",
  "bin": {
    "<agent-name>": "src/cli.ts"
  },
  "scripts": {
    "start": "bun run src/cli.ts",
    "dev": "bun --watch src/cli.ts",
    "build": "tsc --noEmit",
    "test": "bun test"
  },
  "dependencies": {
    "@openrouter/agent": "latest",
    "zod": "latest"
  },
  "devDependencies": {
    "@types/bun": "latest",
    "typescript": "latest"
  }
}

bin エントリーにより、bun link の実行後にエージェントを名前で呼び出せるようになります。ターゲット(src/cli.ts)の1行目には、#!/usr/bin/env bun シバンが必要です。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["bun-types"]
  },
  "include": ["src", "test"]
}
src/config.ts
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';

function positiveNumber(name: string, raw: string): number {
  const n = Number(raw);
  if (!Number.isFinite(n) || n <= 0) {
    throw new Error(`${name} must be a positive number, got: ${JSON.stringify(raw)}`);
  }
  return n;
}

export interface AgentConfig {
  apiKey: string;
  model: string;
  name: string;
  systemPrompt: string;
  maxSteps: number;
  maxCost: number;
  sessionDir: string;
  sessionEnabled: boolean;
  outputMode: 'text' | 'json' | 'quiet';
}

const DEFAULTS: AgentConfig = {
  apiKey: '',
  model: 'anthropic/claude-sonnet-4.6',
  name: 'My Agent',
  systemPrompt: [
    'You are a coding assistant with access to tools for reading, writing, editing, and searching files, and running shell commands.',
    '',
    'Current working directory: {cwd}',
    '',
    'Guidelines:',
    '- Use your tools proactively. Explore the codebase to find answers instead of asking the user.',
    '- Keep working until the task is fully resolved before responding.',
    '- Do not guess or make up information — use your tools to verify.',
    '- Be concise and direct.',
    '- Show file paths clearly when working with files.',
    '- Prefer grep and glob tools over shell commands for file search.',
    '- When editing code, make minimal targeted changes consistent with the existing style.',
  ].join('\n'),
  maxSteps: 20,
  maxCost: 1.0,
  sessionDir: '.sessions',
  sessionEnabled: true,
  outputMode: 'text',
};

export function loadConfig(overrides: Partial<AgentConfig> = {}, opts?: { skipApiKey?: boolean }): AgentConfig {
  let config = { ...DEFAULTS };

  const configPath = resolve('agent.config.json');
  if (existsSync(configPath)) {
    const file = JSON.parse(readFileSync(configPath, 'utf-8'));
    config = { ...config, ...file };
  }

  if (process.env.OPENROUTER_API_KEY) config.apiKey = process.env.OPENROUTER_API_KEY;
  if (process.env.AGENT_MODEL) config.model = process.env.AGENT_MODEL;
  if (process.env.AGENT_MAX_STEPS) config.maxSteps = positiveNumber('AGENT_MAX_STEPS', process.env.AGENT_MAX_STEPS);
  if (process.env.AGENT_MAX_COST) config.maxCost = positiveNumber('AGENT_MAX_COST', process.env.AGENT_MAX_COST);

  config = { ...config, ...overrides };
  if (!config.apiKey && !opts?.skipApiKey) throw new Error('OPENROUTER_API_KEY is required.');
  return config;
}
src/tools/index.ts

チェックリストの選択内容に応じてインポートを調整します。この例には、デフォルトで有効になっているすべてのツールが含まれています。

import { serverTool } from '@openrouter/agent';
import { fileReadTool } from './file-read.js';
import { fileWriteTool } from './file-write.js';
import { fileEditTool } from './file-edit.js';
import { globTool } from './glob.js';
import { grepTool } from './grep.js';
import { listDirTool } from './list-dir.js';
import { shellTool } from './shell.js';
import { myCustomTool } from './custom.js';

// `as const` unlocks full type inference for tool calls downstream.
// See: https://openrouter.ai/docs/agent-sdk/call-model/tools
export const tools = [
  // User-defined tools — executed client-side
  fileReadTool,
  fileWriteTool,
  fileEditTool,
  globTool,
  grepTool,
  listDirTool,
  shellTool,
  myCustomTool,

  // Server tools — executed by OpenRouter, no client implementation needed
  serverTool({ type: 'openrouter:web_search' }),
  serverTool({ type: 'openrouter:web_fetch' }),
  serverTool({ type: 'openrouter:datetime', parameters: { timezone: 'UTC' } }),
] as const;
src/agent.ts
import { OpenRouter } from '@openrouter/agent';
import type { Item } from '@openrouter/agent';
import { stepCountIs, maxCost } from '@openrouter/agent/stop-conditions';
import type { AgentConfig } from './config.js';
import { tools } from './tools/index.js';

export type ChatMessage = { role: 'user' | 'assistant' | 'system'; content: string };

export type AgentEvent =
  | { type: 'text'; delta: string }
  | { type: 'tool_call'; name: string; callId: string; args: Record<string, unknown> }
  | { type: 'tool_result'; name: string; callId: string; output: string }
  | { type: 'reasoning'; delta: string }
  | { type: 'turn_end' }
  | { type: 'done'; usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number } | null | undefined; durationMs: number };

export async function runAgent(
  config: AgentConfig,
  input: string | ChatMessage[],
  options?: { onEvent?: (event: AgentEvent) => void; signal?: AbortSignal },
) {
  const startedAt = Date.now();
  const client = new OpenRouter({ apiKey: config.apiKey });

  const result = client.callModel({
    model: config.model,
    instructions: config.systemPrompt.replace('{cwd}', process.cwd()),
    input: input as string | Item[],
    tools,
    stopWhen: [stepCountIs(config.maxSteps), maxCost(config.maxCost)],
  });

  // Wire AbortSignal → result.cancel() so the underlying network stream
  // actually closes (not just the iterator we're about to walk). Also
  // handle the pre-aborted case: addEventListener('abort') does not fire
  // for signals already in the aborted state.
  const onAbort = () => result.cancel();
  options?.signal?.addEventListener('abort', onAbort);
  if (options?.signal?.aborted) result.cancel();

  // Draining getTextStream concurrently with getItemsStream reads the
  // stream dry, so getResponse().outputText ends up empty. We accumulate
  // text deltas here as a source of truth for the final text.
  let accumulatedText = '';

  try {
    if (options?.onEvent) {
      // Run two streams concurrently: getTextStream for text deltas (no
      // bookkeeping required) and getItemsStream filtered to tool events.
      // The SDK's ReusableReadableStream allows concurrent consumption.
      const callNames = new Map<string, string>();

      const streamText = async () => {
        for await (const delta of result.getTextStream()) {
          if (options?.signal?.aborted) break;
          options.onEvent!({ type: 'text', delta });
          accumulatedText += delta;
        }
      };

      const streamTools = async () => {
        for await (const item of result.getItemsStream()) {
          if (options?.signal?.aborted) break;
          if (item.type === 'function_call') {
            callNames.set(item.callId, item.name);
            if (item.status === 'completed') {
              const args = (() => { try { return item.arguments ? JSON.parse(item.arguments) : {}; } catch { return {}; } })();
              options.onEvent!({ type: 'tool_call', name: item.name, callId: item.callId, args });
            }
          } else if (item.type === 'function_call_output') {
            const out = typeof item.output === 'string' ? item.output : JSON.stringify(item.output);
            options.onEvent!({
              type: 'tool_result',
              name: callNames.get(item.callId) ?? 'unknown',
              callId: item.callId,
              output: out.length > 200 ? out.slice(0, 200) + '...' : out,
            });
            // Signal a turn boundary; consumers (e.g. CLI text mode) can
            // render a separator. Keeps presentation out of agent.ts.
            options.onEvent!({ type: 'turn_end' });
          } else if (item.type === 'reasoning') {
            const text = item.summary?.map((s: { text: string }) => s.text).join('') ?? '';
            if (text) options.onEvent!({ type: 'reasoning', delta: text });
          }
        }
      };

      await Promise.all([streamText(), streamTools()]);
    }

    const response = await result.getResponse();
    const durationMs = Date.now() - startedAt;
    const text = accumulatedText || (response.outputText ?? '');
    options?.onEvent?.({ type: 'done', usage: response.usage, durationMs });
    return { text, usage: response.usage, output: response.output, durationMs };
  } finally {
    options?.signal?.removeEventListener('abort', onAbort);
  }
}

/**
 * Retry on 429/5xx — but ONLY if no tool calls have been executed yet.
 * Once a mutating tool (file_write, shell, etc.) has run, replaying the
 * whole agent from the initial prompt would double-execute side effects.
 * For mid-run resilience, use a StateAccessor (see references/modules.md).
 */
export async function runAgentWithRetry(
  config: AgentConfig,
  input: string | ChatMessage[],
  options?: { onEvent?: (event: AgentEvent) => void; signal?: AbortSignal; maxRetries?: number },
) {
  for (let attempt = 0, max = options?.maxRetries ?? 3; attempt <= max; attempt++) {
    let toolCallsMade = 0;
    const wrappedOptions = {
      ...options,
      onEvent: (event: AgentEvent) => {
        if (event.type === 'tool_call') toolCallsMade++;
        options?.onEvent?.(event);
      },
    };
    try {
      return await runAgent(config, input, wrappedOptions);
    } catch (err: any) {
      const s = err?.status ?? err?.statusCode;
      const retryable = s === 429 || (s >= 500 && s < 600);
      if (!retryable || attempt === max || toolCallsMade > 0) throw err;
      await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** attempt, 30000)));
    }
  }
  throw new Error('Unreachable');
}
src/cli.ts

ヘッドレス CLI のエントリーポイントです。引数を解析し、標準入力を読み取り、エージェントに処理を振り分けて終了します。完全な実装については、references/entry-points.md を参照してください。

import { parseArgs } from 'util';
import { loadConfig } from './config.js';
import { runAgentWithRetry, type AgentEvent } from './agent.js';
import { initSessionDir, saveMessage, newSessionPath } from './session.js';

const { values, positionals } = parseArgs({
  args: process.argv.slice(2),
  options: {
    prompt: { type: 'string', short: 'p' },
    json: { type: 'boolean', short: 'j', default: false },
    quiet: { type: 'boolean', short: 'q', default: false },
    'no-session': { type: 'boolean', default: false },
    model: { type: 'string', short: 'm' },
    'max-steps': { type: 'string' },
    'max-cost': { type: 'string' },
    help: { type: 'boolean', short: 'h', default: false },
  },
  allowPositionals: true,
});

// ... resolve prompt from args, positional, or stdin
// ... call loadConfig with overrides
// ... call runAgentWithRetry with appropriate event handler
// ... exit with code 0 on success, 1 on error

src/cli.ts、src/server.ts、src/mcp-server.ts の完全な実装については、references/entry-points.md を参照してください。


リファレンスファイル

コアファイル以外の内容については、次を参照してください。

  • references/tools.md -- ユーザー定義ツールの仕様:file-read、file-write、file-edit、glob、grep、list-dir、shell、web-fetch、js-repl、sub-agent、view-image、カスタムテンプレート
  • references/modules.md -- エージェントモジュール:セッションの永続化、コンテキストの圧縮、システムプロンプトの構成、ツールの承認、構造化ログ、出力スキーマの検証、Webhook 通知
  • references/entry-points.md -- エントリーポイントの仕様:CLI(完全な実装)、SSE を使用する HTTP サーバー、stdio 経由の MCP サーバー

コメント