| name | create-headless-agent |
| description | Scaffolds a headless agent in TypeScript using @openrouter/agent and Bun — for CLI tools, API servers, queue workers, and pipelines. No terminal UI. Use when building a headless agent, programmatic agent, CLI tool that uses AI, batch agent, pipeline agent, API agent, agent without a UI, or agent service. |
Create Headless Agent
Scaffolds a headless agent in TypeScript targeting OpenRouter. The generated project uses @openrouter/agent for the inner loop (model calls, tool execution, stop conditions) and provides a clean programmatic shell: configuration, session management, tool definitions, and one or more entry points (CLI, HTTP server, MCP server, or library import). No terminal UI, no readline, no ANSI — just input in, result out.
Prerequisites
Decision Tree
Interactive Feature Checklist
Present this as a multi-select checklist. Items marked ON are pre-selected defaults.
Entry Points (pick one or more)
| Entry Point | Default | Description |
|---|
| CLI | ON | args/stdin to agent to stdout, --json for NDJSON |
| Library module | ON | import { runAgent } from './agent' |
| HTTP server | OFF | Bun.serve() with SSE streaming |
| MCP server | OFF | Expose as MCP tool via stdio |
OpenRouter Server Tools (server-side, zero implementation)
| Tool | Type string | Default |
|---|
| Web Search | openrouter:web_search | ON |
| Web Fetch | openrouter:web_fetch | ON |
| Datetime | openrouter:datetime | ON |
| Image Generation | openrouter:image_generation | OFF |
Server tools go in the tools array alongside user-defined tools. No client code needed — OpenRouter executes them. Docs: openrouter.ai/docs/guides/features/server-tools.
User-Defined Tools (client-side, generated into src/tools/)
| Tool | Default | Description |
|---|
| File Read | ON | Read files with offset/limit |
| File Write | ON | Write/create files, auto-create directories |
| File Edit | ON | Search-and-replace with diff validation |
| Glob/Find | ON | File discovery by glob pattern |
| Grep/Search | ON | Content search by regex |
| Directory List | ON | List directory contents |
| Shell/Bash | ON | Execute commands with timeout and output capture |
| Custom Tool Template | ON | Empty skeleton for domain-specific tools |
| JS/TS REPL | OFF | Persistent Bun REPL |
| Sub-agent Spawn | OFF | Delegate tasks to child agents |
| View Image | OFF | Read local images as base64 |
Agent Modules (architectural components)
| Module | Default | Description |
|---|
| Session Persistence | ON | JSONL conversation log, --no-session to disable |
| Retry with Backoff | ON | Built into agent.ts |
| Context Compaction | OFF | Summarize when context is long |
| Tool Result Offload | OFF | Persist oversized tool outputs to disk, keep preview in context |
| System Prompt Composition | OFF | Dynamic instructions from context files |
| Tool Approval Flow | OFF | Programmatic approve/reject |
| Structured Event Logging | OFF | JSON events to stderr |
| Output Schema Validation | OFF | Zod schema constraining response |
| Webhook Notifications | OFF | POST on completion |
CLI Output Mode (single-select, if CLI entry point is ON)
| Mode | Default | Description |
|---|
| Text | ON | Final response text to stdout |
| JSON | OFF | NDJSON event stream |
| Quiet | OFF | Exit code only |
Generation Workflow
Before generating, ask the user what to name their agent. This name is used as:
- the
"name" field in package.json
- the
"bin" command (so bun link makes it a globally-invokable CLI)
- the project directory name (if creating a new directory)
Suggested question: "What would you like to call your agent? (short kebab-case, e.g. research-bot or docs-helper)". Validate the answer is a valid npm package name (lowercase, kebab-case, no spaces). Default to my-agent if the user has no preference. Use the chosen name everywhere the workflow below shows <agent-name>.
After getting the name and checklist selections, follow this workflow:
- [ ] 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)
After generation, the user can run their agent from any directory:
<agent-name> "What's in this repo?"
echo "Summarize README.md" | <agent-name>
<agent-name> --json "List all TODOs" | jq .
To later rename the agent, update the name and bin keys in package.json, then run bun unlink && bun link.
Tool Pattern
All user-defined tools follow this pattern using @openrouter/agent/tool. Here is one complete example — all other tools in references/tools.md follow the same shape:
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();
start = offset ? offset - : ;
end = .(start + (limit ?? ), lines.);
longLines = ;
slice = lines.(start, end).( {
(line. <= ) line;
longLines++;
line.(, ) + ;
});
tailTruncated = end < lines.;
truncated = tailTruncated || longLines > ;
: [] = [];
(tailTruncated) hintParts.();
(longLines > ) hintParts.();
{
: slice.(),
: lines.,
...(truncated && {
: ,
...(tailTruncated && { : end + }),
: hintParts.(),
}),
};
} (: ) {
(err. === ) { : };
(err. === ) { : };
{ : err. };
}
},
});
For specs of all other tools, see references/tools.md.
Core Files
These files are always generated. The agent adapts them based on checklist selections.
package.json
Initialize the project and install dependencies. Replace <agent-name> with the name the user chose:
bun init -y
{
"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":
The bin entry is what makes the agent invokable by name after bun link. The target (src/cli.ts) must have a #!/usr/bin/env bun shebang on the first line.
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 = {
: ,
: ,
: ,
: [
,
,
,
,
,
,
,
,
,
,
,
,
].(),
: ,
: ,
: ,
: ,
: ,
};
(): {
config = { ... };
configPath = ();
((configPath)) {
file = .((configPath, ));
config = { ...config, ...file };
}
(process..) config. = process..;
(process..) config. = process..;
(process..) config. = (, process..);
(process..) config. = (, process..);
config = { ...config, ...overrides };
(!config. && !opts?.) ();
config;
}
src/tools/index.ts
Adapt imports based on checklist selections. This example includes all default-ON tools:
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';
export const tools = [
fileReadTool,
fileWriteTool,
fileEditTool,
globTool,
grepTool,
listDirTool,
shellTool,
myCustomTool,
serverTool({ type: 'openrouter:web_search' }),
serverTool({ type: 'openrouter:web_fetch' }),
serverTool({ type: 'openrouter:datetime', parameters: { timezone: 'UTC' } }),
] as ;
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 }
| { : ; : }
| { : }
| { : ; : { ?: ; ?: ; ?: } | | ; : };
() {
startedAt = .();
client = ({ : config. });
result = client.({
: config.,
: config..(, process.()),
: input | [],
tools,
: [(config.), (config.)],
});
= () => result.();
options?.?.(, onAbort);
(options?.?.) result.();
accumulatedText = ;
{
(options?.) {
callNames = <, >();
= () => {
( delta result.()) {
(options?.?.) ;
options.!({ : , delta });
accumulatedText += delta;
}
};
= () => {
( item result.()) {
(options?.?.) ;
(item. === ) {
callNames.(item., item.);
(item. === ) {
args = ( { { item. ? .(item.) : {}; } { {}; } })();
options.!({ : , : item., : item., args });
}
} (item. === ) {
out = item. === ? item. : .(item.);
options.!({
: ,
: callNames.(item.) ?? ,
: item.,
: out. > ? out.(, ) + : out,
});
options.!({ : });
} (item. === ) {
text = item.?.( s.).() ?? ;
(text) options.!({ : , : text });
}
}
};
.([(), ()]);
}
response = result.();
durationMs = .() - startedAt;
text = accumulatedText || (response. ?? );
options?.?.({ : , : response., durationMs });
{ text, : response., : response., durationMs };
} {
options?.?.(, onAbort);
}
}
() {
( attempt = , max = options?. ?? ; attempt <= max; attempt++) {
toolCallsMade = ;
wrappedOptions = {
...options,
: {
(event. === ) toolCallsMade++;
options?.?.(event);
},
};
{
(config, input, wrappedOptions);
} (: ) {
s = err?. ?? err?.;
retryable = s === || (s >= && s < );
(!retryable || attempt === max || toolCallsMade > ) err;
( (r, .( * ** attempt, )));
}
}
();
}
src/cli.ts
Headless CLI entry point — parses args, reads stdin, dispatches to the agent, and exits. See references/entry-points.md for the complete implementation.
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': { : },
: { : , : , : },
},
: ,
});
See references/entry-points.md for the complete src/cli.ts, src/server.ts, and src/mcp-server.ts implementations.
Reference Files
For content beyond the core files:
- references/tools.md -- Specs for all user-defined tools: file-read, file-write, file-edit, glob, grep, list-dir, shell, web-fetch, js-repl, sub-agent, view-image, custom template
- references/modules.md -- Agent modules: session persistence, context compaction, system prompt composition, tool approval, structured logging, output schema validation, webhook notifications
- references/entry-points.md -- Entry point specs: CLI (full implementation), HTTP server with SSE, MCP server via stdio