create-headless-agent

star 169

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.

OpenRouterTeam By OpenRouterTeam schedule Updated 4/28/2026

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

User wants to... Action
Build a new headless agent Present checklist below, follow Generation Workflow
Add tools to an existing agent Read references/tools.md, present tool checklist only
Add a module Read references/modules.md, generate the module
Add an entry point Read references/entry-points.md, generate it

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('\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 };
    }
  },
});

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
# 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"
  }
}

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 = {
  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

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';

// `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

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': { 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

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
Install via CLI
npx skills add https://github.com/OpenRouterTeam/skills --skill create-headless-agent
Repository Details
star Stars 169
call_split Forks 21
navigation Branch main
article Path SKILL.md
More from Creator
OpenRouterTeam
OpenRouterTeam Explore all skills →