关于 create-headless-agent
create-headless-agent 是用于搭建 TypeScript 无头 Agent 的技能,基于 OpenRouter 和 @openrouter/agent。它引导选择入口、工具和模块,并生成不含终端界面的程序化 Agent。
主要功能
- 01交互式选择入口和工具
- 02CLI、库、HTTP 与 MCP 服务端入口
- 03生成文件和 Shell 工具
- 04会话持久化与重试模块
- 05OpenRouter 服务端工具选项
适用场景
- 01启动 TypeScript Agent 项目
- 02为现有 Agent 添加工具或模块
- 03通过 CLI、HTTP 或 MCP 暴露 Agent
- 04创建具备文件和 Shell 能力的 Agent
MCP 相关
可生成 MCP 服务端入口,通过 stdio 将 Agent 暴露为 MCP 工具。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ openrouterteam/ skills --skill create-headless-agent安装后在 Claude Code 中输入/create-headless-agent确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
metadata.jsonREADME.mdreferences/entry-points.mdmodules.mdtools.mdsample/.env.example.gitignoreagent.config.jsonbun.lockpackage.jsontsconfig.jsonsample/src/agent.tscli.tsconfig.tssession.tssample/src/tools/custom.tsfile-edit.tsfile-read.tsfile-write.tsglob.tsgrep.tsindex.tslist-dir.tsshell.tssample/test/agent.test.tsoutput-schema.test.tsretry.test.ts详细介绍
创建无头 Agent
搭建一个面向 OpenRouter 的 TypeScript 无头 Agent。生成的项目使用 @openrouter/agent 处理内部循环(模型调用、工具执行、停止条件),并提供简洁的程序化外壳:配置、会话管理、工具定义,以及一个或多个入口点(CLI、HTTP 服务、MCP 服务或库导入)。没有终端 UI、没有 readline、没有 ANSI——只需输入,即可获得结果。
前置条件
- Bun 1.1+
- 从 openrouter.ai/settings/keys 获取的
OPENROUTER_API_KEY - 完整的 SDK 参考请参阅
openrouter-typescript-sdk技能
决策树
| 用户想要…… | 操作 |
|---|---|
| 构建新的无头 Agent | 展示以下清单,并遵循生成工作流 |
| 为现有 Agent 添加工具 | 阅读 references/tools.md,仅展示工具清单 |
| 添加模块 | 阅读 references/modules.md,生成模块 |
| 添加入口点 | 阅读 references/entry-points.md,生成入口点 |
交互式功能清单
以多选清单的形式展示。标记为 ON 的项目为默认预选项。
入口点(选择一个或多个)
| 入口点 | 默认值 | 描述 |
|---|---|---|
| CLI | ON | 参数/标准输入传给 Agent,结果输出到标准输出;使用 --json 输出 NDJSON |
| 库模块 | ON | import { runAgent } from './agent' |
| HTTP 服务 | OFF | 使用 Bun.serve() 进行 SSE 流式传输 |
| MCP 服务 | OFF | 通过标准输入/输出将其公开为 MCP 工具 |
OpenRouter 服务端工具(服务端执行,无需实现)
| 工具 | 类型字符串 | 默认值 |
|---|---|---|
| 网页搜索 | openrouter:web_search | ON |
| 网页抓取 | openrouter:web_fetch | ON |
| 日期时间 | openrouter:datetime | ON |
| 图像生成 | openrouter:image_generation | OFF |
服务端工具与用户定义的工具一同放入 tools 数组中。无需客户端代码——由 OpenRouter 执行。文档:openrouter.ai/docs/guides/features/server-tools。
用户定义的工具(客户端执行,生成到 src/tools/)
| 工具 | 默认值 | 描述 |
|---|---|---|
| 文件读取 | ON | 按偏移量/限制读取文件 |
| 文件写入 | ON | 写入/创建文件,并自动创建目录 |
| 文件编辑 | ON | 通过差异验证进行查找和替换 |
| Glob/查找 | ON | 按 glob 模式查找文件 |
| Grep/搜索 | ON | 使用正则表达式搜索内容 |
| 目录列表 | ON | 列出目录内容 |
| Shell/Bash | ON | 执行命令,支持超时和输出捕获 |
| 自定义工具模板 | ON | 面向特定领域工具的空白框架 |
| JS/TS REPL | OFF | 持久化 Bun REPL |
| 子 Agent 启动 | OFF | 将任务委派给子 Agent |
| 查看图像 | OFF | 将本地图像读取为 base64 |
Agent 模块(架构组件)
| 模块 | 默认值 | 描述 |
|---|---|---|
| 会话持久化 | ON | JSONL 对话日志;使用 --no-session 禁用 |
| 带退避的重试 | ON | 内置于 agent.ts |
| 上下文压缩 | OFF | 上下文过长时进行摘要 |
| 工具结果转存 | OFF | 将过大的工具输出持久化到磁盘,并在上下文中保留预览 |
| 系统提示词组合 | OFF | 根据上下文文件动态生成指令 |
| 工具审批流程 | OFF | 以编程方式批准/拒绝 |
| 结构化事件日志 | OFF | 将 JSON 事件输出到 stderr |
| 输出模式验证 | OFF | 使用 Zod 模式约束响应 |
| Webhook 通知 | OFF | 完成时发送 POST 请求 |
CLI 输出模式(单选,仅当 CLI 入口点为 ON 时)
| 模式 | 默认值 | 描述 |
|---|---|---|
| 文本 | ON | 将最终响应文本输出到标准输出 |
| JSON | OFF | NDJSON 事件流 |
| 静默 | OFF | 仅返回退出码 |
生成工作流
生成前,先询问用户想给自己的 Agent 起什么名字。此名称将用于:
package.json中的"name"字段"bin"命令(这样bun link就能将其设为可全局调用的 CLI)- 项目目录名称(如果要创建新目录)
建议提问:“你想给自己的 Agent 起什么名字?(简短的 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:
<agent-name> "What's in this repo?"
echo "Summarize README.md" | <agent-name>
<agent-name> --json "List all TODOs" | jq .
之后如需重命名 Agent,请更新 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。
核心文件
始终会生成以下文件。Agent 会根据清单选择进行调整。
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 条目使 Agent 能够在执行 bun link 后通过名称调用。目标文件(src/cli.ts)的第一行必须包含 #!/usr/bin/env bun shebang。
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 入口点——解析参数、读取标准输入、将请求分派给 Agent,然后退出。完整实现请参阅 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 -- 所有用户自定义工具的规格:文件读取、文件写入、文件编辑、glob、grep、目录列表、shell、网页抓取、js-repl、子 Agent、查看图像、自定义模板
- references/modules.md -- Agent 模块:会话持久化、上下文压缩、系统提示词组合、工具审批、结构化日志记录、输出架构验证、Webhook 通知
- references/entry-points.md -- 入口点规格:CLI(完整实现)、带 SSE 的 HTTP 服务、通过 stdio 运行的 MCP 服务