A core library for running AI Agent CLIs in headless mode from Node.js.
It allows you to run local CLIs such as Claude Code, Codex, Grok, Agy, and Devin using the same run() API, progress events, fallback hooks, and model selection mechanisms.
- CLI execution for Claude Code / Codex / Grok / Agy / Devin
- TypeScript API
- Progress notifications for intermediate output (and claude tool calls) via
onProgress - Opt-in tool use for claude (clone repositories, read files, fetch the web)
- Cancellation using
timeoutMsandAbortSignal - Provider-specific model and reasoning effort option conversion
- Failure classification and fallback hooks
- Model candidate management via a shared
models.jsonfile - Remaining plan usage (5-hour / weekly / ...) per agent via
getUsageLimitsandheadless-core usage headless-core models init|inspectCLI
A minimal chat UI sample is provided.
npm run exampleAfter starting, open it in your browser.
http://127.0.0.1:4173You can override the address with the PORT / HOST environment variables. If the port is already in use, the next ports are tried automatically (up to 20).
For details, please refer to example/README.md.
Install the package from npm: @headless-core/core
npm install @headless-core/coreTo try it with local development:
npm install
npm run buildimport { createHeadlessCore } from "@headless-core/core";
const headless = createHeadlessCore({
cwd: process.cwd(),
timeoutMs: 120_000
});
const output = await headless.run({
agent: {
provider: "codex",
model: "default",
reasoningEffort: "medium"
},
prompt: "Say hello in one line."
});
console.log(output);Execution example:
Hello.If model: "default" is specified, the --model option is not passed to the provider CLI.
flowchart TD
App[Your app] --> SDK[headless_core]
SDK --> Adapter[Provider adapter]
Adapter --> CLI[Local AI Agent CLI]
CLI --> Provider[Claude / OpenAI / xAI / other provider]
headless_core is not an API proxy. It runs the AI Agent CLI located in the user's local environment via spawn, and handles its output and exit status.
const headless = createHeadlessCore({
cwd: "/path/to/project",
timeoutMs: 120_000,
env: process.env
});const output = await headless.run({
agent: { provider: "claude", model: "opus", reasoningEffort: "high" },
prompt: "Summarize this project.",
onProgress(event) {
console.log(event.state, event.partialOutput ?? event.message);
},
onFallback({ error, prompt }) {
if (error.kind === "rate_limit") {
return {
type: "rerun",
agent: { provider: "codex", model: "default" },
prompt
};
}
return { type: "fail" };
}
});import { getAvailableModels } from "@headless-core/core";
const models = await getAvailableModels({ agent: "codex" });import { getAvailableReasoningEffortOptions } from "@headless-core/core";
const efforts = getAvailableReasoningEffortOptions({ agent: "claude" });const session = await headless.createSession({
agent: { provider: "codex", transport: "app-server", model: "default" }
});Reads the remaining plan usage (5-hour / daily / weekly / monthly windows) of each agent. Providers report usage as a percentage of the plan allowance, not as token counts.
const limits = await headless.getAllUsageLimits();
// [{ provider: "claude", supported: true, available: true, plan: "pro",
// windows: [{ id: "five_hour", period: "five_hour", label: "5-hour",
// usedPercent: 12, remainingPercent: 88, resetsAt: "2026-10-29T12:20:00Z", windowMinutes: 300 }, ...] }, ...]
const codex = await headless.getUsageLimits({ agent: "codex" });| Agent | Source | Windows |
|---|---|---|
| codex | codex app-server account/rateLimits/read |
Whatever the plan defines (e.g. 5-hour + weekly, or monthly on free) |
| claude | Claude Agent SDK /usage data (requires @anthropic-ai/claude-agent-sdk and a claude.ai login) |
5-hour, weekly, per-model weekly |
| agy / grok / devin | — | supported: false (the CLIs expose no plan usage) |
Provider-side failures never throw: they come back as available: false with error. No prompt is sent, so reading usage does not consume any. The claude source is an experimental SDK API and may change.
Closes all sessions and terminates every persistent agent process owned by this core.
run() spawns a fresh CLI process per call (transport cli, the default). For lower latency and conversation continuity, createSession() keeps a persistent agent process alive and reuses it across session.run() calls:
| provider | transport |
persistent process |
|---|---|---|
codex |
app-server |
codex app-server (JSON-RPC over stdio) |
agy |
acp |
agy_acp_server (Agent Client Protocol over stdio) |
grok |
acp |
grok agent --always-approve stdio (Agent Client Protocol over stdio) |
devin |
acp |
devin acp (Agent Client Protocol over stdio) |
claude |
sdk |
Claude Agent SDK streaming-input query (@anthropic-ai/claude-agent-sdk) |
| any | cli (default) |
one-shot spawn per run() |
Sessions that share the same provider/transport/binary share one OS process; each session owns its own conversation (a codex thread or an ACP session). Concurrent run() calls on the same session are serialized; different sessions run in parallel. The process exits when the last session closes.
const session = await headless.createSession({
agent: { provider: "codex", transport: "app-server" }
});
await session.run({
prompt: "Hello",
onProgress(event) {
console.log(event.state, event.partialOutput);
}
});
await session.run({ prompt: "Follow-up on the same thread" });
// New conversation, same process:
await session.reset();
// Dynamic model list / switching without restarting the process:
const models = await session.getAvailableModels();
await session.setModel("MODEL_ID", "low");
await session.close();
// Devin, Grok, and Agy use the same API over ACP
// (`devin acp` / `grok agent stdio` / `agy_acp_server`):
const devin = await headless.createSession({
agent: { provider: "devin", transport: "acp" }
});
await devin.run({ prompt: "Hello" });
await devin.close();
const grok = await headless.createSession({
agent: { provider: "grok", transport: "acp" }
});
await grok.run({ prompt: "Hello" });
await grok.close();
const agy = await headless.createSession({
agent: { provider: "agy", transport: "acp" }
});
await agy.run({ prompt: "Hello" });
await agy.close();
// Claude uses the Claude Agent SDK (`npm install @anthropic-ai/claude-agent-sdk`):
const claude = await headless.createSession({
agent: { provider: "claude", transport: "sdk", model: "sonnet", reasoningEffort: "high" }
});
await claude.run({ prompt: "Hello" });
await claude.close();
// Kill every persistent process owned by this core:
await headless.shutdown();session.run() accepts prompt, signal (AbortSignal), timeoutMs, and onProgress — the same progress events as headless.run(). Abort and timeout cancel the remote turn (turn/interrupt / session/cancel), so the session stays reusable. The headless permission posture matches each provider's CLI path: codex threads run with sandbox: "read-only" and approvalPolicy: "never" (approval requests are declined automatically), devin sessions select the answer-only ask mode with permission requests denied by default, grok sessions run grok agent --always-approve (matching grok --single headless runs, which never pause for tool prompts) and auto-approve permission requests, and agy sessions select the most capable advertised mode (accept-edits, auto_edit, or yolo — the names differ per server build) and auto-approve permission requests (matching agy --print --dangerously-skip-permissions).
The agy acp transport spawns the standalone agy_acp_server binary (the antigravity-acp ACP registry entry), not the agy CLI. It is resolved from AGY_ACP_BIN, or agy_acp_server / agy_acp_server.par on PATH (agy_acp_server.exe on Windows). Models and reasoning effort are applied through the session's ACP config options: a level-embedded variant id (gemini-3-8-flash-low) is selected when advertised, and in-range levels otherwise go to the session's effort option (effort, thought_level, ...).
The acp transport needs the official Google Antigravity ACP server, which is distributed through the ACP registry (registry id antigravity-acp; binary agy_acp_server.par, or agy_acp_server.exe on Windows). Check the registry for the latest version and the download URL for your platform — the walkthrough below follows the setup wiki:
mkdir -p ~/.local/share/antigravity-acp && cd ~/.local/share/antigravity-acp
uname -m # arm64 (Apple Silicon) or x86_64 (Intel)
curl -L -o agy-acp.zip \
https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.2.1-darwin-arm64.zip
# Intel Macs: use ...-darwin-x86_64.zip; see the registry for Linux/Windows builds
unzip agy-acp.zip && chmod +x agy_acp_server.par
xattr -cr ~/.local/share/antigravity-acp # only if Gatekeeper blocks executionThen either export AGY_ACP_BIN=~/.local/share/antigravity-acp/agy_acp_server.par, or put it on PATH via a symlink:
mkdir -p ~/.local/bin
ln -s ~/.local/share/antigravity-acp/agy_acp_server.par ~/.local/bin/agy_acp_serverThe server authenticates with your Google account using the Antigravity credentials under ~/.gemini/ (shared with the Antigravity app / agy CLI). Sign in there first — once credentials exist, initialize/session/new work without an interactive flow.
The claude sdk transport runs @anthropic-ai/claude-agent-sdk in streaming-input mode. The SDK is an optional peer dependency loaded on first use, so install it next to @headless-core/core when you need this transport:
npm install @anthropic-ai/claude-agent-sdkUnlike the other persistent transports, each sdk session owns its own Claude Code process (one SDK query per conversation); reset() replaces that query and the session id changes. Authentication follows the SDK/Claude Code rules (ANTHROPIC_API_KEY, or an existing Claude Code login). The headless posture matches the claude cli path (--tools ""): built-in tools are disabled (tools: []), permissionMode: "dontAsk" denies anything not pre-approved, and persistSession: false keeps conversations in memory only (see Tool use to opt in to tools). Set CLAUDE_SDK_BIN to an installed claude to run it instead of the executable bundled with the SDK (pathToClaudeCodeExecutable). setModel() calls the query's setModel() and applies effort through applyFlagSettings({ effortLevel }); default clears either override. getAvailableModels() returns the SDK's supportedModels() catalog, and efforts are validated against each model's supportedEffortLevels before they are sent.
The grok acp transport spawns grok agent --always-approve stdio from the same Grok Build binary the cli transport uses (grok or GROK_BIN) — no separate server to install. It authenticates with the cached_token method (~/.grok/auth.json), so run grok login (or use the TUI) once first. session/new advertises a models catalog plus model/reasoning_effort config options: setModel() selects the model and applies the effort through the session's effort option, validated against the levels each model advertises (low/medium/high/xhigh on reasoning models). Passing default restores the model/effort the session started with.
For devin ACP sessions the model config option advertises variant uids that embed the thinking level (swe-2-high, claude-opus-5-5-medium), while models.json typically holds family slugs (swe-2, claude-opus-5.5). setModel()/session creation resolve them in this order: the effort-folded uid (claude-opus-5.5 + high -> claude-opus-5-5-high), the exact or dashed id, then the family's only advertised variant (swe-2 -> swe-2-high, gpt-6-astra -> gpt-6-astra-medium). Any remaining effort is applied via the thought_level config option when the session offers it. Passing default restores the model/thought level the session started with.
onProgress receives ProgressEvents (headless.run() and session.run() alike):
| state | partialOutput |
notes |
|---|---|---|
starting |
— | message names the provider |
running |
An incremental chunk (delta), not the text so far. Concatenate the chunks to rebuild the streamed output | Tool calls arrive as running events with toolUse set (claude with agent.tools) |
completed |
The full final output (same as the resolved value) | |
failed |
The output collected before the failure | error.kind is agent_stopped for timeout/abort |
fallback |
— | onFallback is about to run |
By default every run is text-in/text-out: claude starts with --tools "" (cli) or tools: [] (sdk), so it cannot clone a repository or read the web. Set agent.tools to opt in:
const output = await headless.run({
agent: {
provider: "claude",
tools: {
allow: ["Bash", "Read", "Grep", "Glob", "WebFetch"],
permissionMode: "dontAsk",
allowedRules: ["Bash(git clone:*)", "WebFetch(domain:github.com)"]
}
},
prompt: "Clone https://github.com/owner/repo and summarize its README.",
cwd: "/path/to/workspaces/conversation-42",
timeoutMs: 600_000,
onProgress(event) {
if (event.toolUse) {
console.log(event.toolUse.status, event.toolUse.name, event.toolUse.input ?? event.toolUse.output);
} else if (event.partialOutput && event.state === "running") {
process.stdout.write(event.partialOutput);
}
}
});| field | description |
|---|---|
allow |
Built-in tools to make available. Omitted tools or an empty allow keeps the text-only posture (identical arguments to before) |
permissionMode |
dontAsk (default): only pre-approved calls run. acceptEdits: also accepts file edits in cwd. bypassPermissions: no permission checks |
allowedRules |
Pre-approved permission rules (claude --allowedTools), e.g. Bash(git clone:*). A compound Bash command (cd x && git clone ...) does not match a git clone rule |
| transport | mapping |
|---|---|
cli |
--output-format stream-json --verbose --include-partial-messages --tools <allow joined by ","> --permission-mode <mode> --permission-prompts none [--allowedTools <rules...>] -- <prompt> |
sdk |
tools, permissionMode, allowedTools, permissionPrompts: "none" (plus allowDangerouslySkipPermissions for bypassPermissions) |
- A run never waits for approval: anything not allowed is denied at once, Claude is told so, and the run continues. The denial is reported as a
toolUseevent withstatus: "denied"and the run completes normally — a timeout, in contrast, fails witherror.kind: "agent_stopped". toolUseevents:started(withinput) →completed/failed(tool error, withoutput) ordenied(with the reason inoutput).parentToolUseIdis set for calls made inside a subagent. Only top-level text (not subagent text) is streamed aspartialOutput, and theresultmessage becomes the final output.- The agent works in
cwd—HeadlessCoreConfig.cwd, or the per-callcwdonrun()/createSession()(e.g. one clone directory per conversation). Theclitransport usesstream-jsononly when tools are enabled; text-only runs keep--output-format text. - Any other provider rejects
agent.toolswithToolsError(UNSUPPORTED_TOOLS); a malformed spec raisesINVALID_TOOLS. Neither is routed throughonFallback. - Tool runs take longer than text answers — raise
timeoutMsaccordingly.
agent.reasoningEffort accepts a shared vocabulary across providers:
type Effort = "default" | "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";The values are not interchangeable aliases:
default— do not send an explicit effort; the provider/CLI/model default applies. OmittingreasoningEffortbehaves identically.none— fully disable reasoning/thinking. Distinct fromminimal; it is only honored when the model exposes a real off switch (an advertisednonelevel or a non-thinking variant). It is never silently converted tominimal/low.minimal— the smallest level that still runs reasoning, not "off".low/medium/high/xhigh/max— increasing effort as supported.
Each adapter maps a level to the provider's native mechanism and validates it against model capabilities before the request is sent (capability data comes from the runtime where available — codex debug models, the app-server model/list, devin models list, ACP config options, agy models). Unsupported values raise an EffortError:
INVALID_EFFORT— the value is outside the vocabulary (e.g."banana").UNSUPPORTED_EFFORT— valid value, but the selected model cannot express it. The error message lists the model's supported efforts; no implicit fallback is applied.
Per-provider mapping:
| provider | mechanism | notes |
|---|---|---|
| Codex | model_reasoning_effort (CLI -c / app-server turn/start effort) |
Levels pass through verbatim. Per-model support is enforced from supported_reasoning_levels / supportedReasoningEfforts. default omits the parameter |
| Claude Code | --effort (cli) / Agent SDK effort option and effortLevel flag setting (sdk) |
Accepts low, medium, high, xhigh, max. On sdk, per-model support is enforced from supportedModels() |
| Grok | --effort (cli) / ACP reasoning_effort config option (acp) |
Accepts low, medium, high, xhigh. On acp, per-model support is enforced from the session's models catalog |
| Agy (Gemini-family) | level-embedded variant ids (--model gemini-3-8-flash-low) or --effort |
A variant carrying the requested level wins; none maps only to an explicit non-thinking variant (gemini-2-5-flash-none), never to minimal; remaining in-range levels use `--effort low |
| Devin | variant uids (--model <model>-<level>) or ACP thought_level |
none maps only to an explicit non-reasoning variant (gpt-5-4-none, or the family's level-less sibling among thinking variants) — never to a mechanical <model>-none. Other levels must match an advertised variant or thought_level |
When capability data cannot be fetched, codex/agy degrade to provider-side validation; devin none is always rejected without a catalog since only an advertised variant may carry it.
Devin capabilities are read per transport, and they do not always agree:
cli—devin models listadvertises every variant uid, including non-thinking variants (gpt-6-luna-none,gpt-5-4-none).acp— the session'smodelconfig option advertises a curated subset (e.g. onlygpt-6-luna-medium), andsession/set_config_optionrejects values outside that list (Invalid value ... for config option 'model').
So an effort can be supported on cli while unsupported on acp: gpt-6-luna + none resolves to gpt-6-luna-none on cli, but on acp it fails with UNSUPPORTED_EFFORT because the session never offered that variant. supportedEfforts in the error always reflects the selected transport's actual options.
| option | description |
|---|---|
cwd |
Working directory where the Agent CLI is executed. run() / createSession() accept cwd to override it per call |
timeoutMs |
Default execution timeout. Defaults to 120 seconds if not specified |
env |
Environment variables passed to the Agent CLI |
| option | description |
|---|---|
agent.provider |
codex, claude, grok, agy, devin |
agent.transport |
cli (default), app-server (codex), acp (agy, grok, devin), sdk (claude). See Persistent sessions |
agent.model |
Model ID passed to the provider. If default, --model is not passed |
agent.reasoningEffort |
Shared effort vocabulary (default, none, minimal, low, medium, high, xhigh, max). default/omitted sends no effort; unsupported values raise EffortError. See Reasoning effort |
agent.tools |
Opt-in tool use (claude only): { allow, permissionMode?, allowedRules? }. Omitted = no tools. See Tool use |
prompt |
Instructions passed to the Agent CLI |
cwd |
Working directory for this run (overrides createHeadlessCore({ cwd })) |
onProgress |
Callback for state changes and intermediate output. See Progress events |
onFallback |
Fallback callback on failure |
signal |
AbortSignal to abort execution |
timeoutMs |
Timeout specific to this execution |
| agent | binary | model option | reasoning effort |
|---|---|---|---|
| Codex | codex or CODEX_BIN |
--model |
--config model_reasoning_effort="..." |
| Claude Code | claude or CLAUDE_BIN (cli); @anthropic-ai/claude-agent-sdk (sdk; CLAUDE_SDK_BIN selects the claude executable) |
--model (cli) / SDK model option (sdk) |
--effort (cli) / SDK effort option (sdk) |
| Grok | grok or GROK_BIN (both transports; acp runs grok agent --always-approve stdio) |
--model (cli) / ACP model config option (acp) |
`--effort low |
| Agy | agy or AGY_BIN (cli); agy_acp_server or AGY_ACP_BIN (acp) |
--model |
Level-embedded variant ids win (gemini-3-8-flash-low); otherwise `--effort low |
| Devin | devin or DEVIN_BIN |
--model |
Folded into the model uid: model + effort becomes --model <model>-<effort> (e.g. claude-opus-5 + high -> claude-opus-5-high). Requires an explicit model; only advertised variants are used — none maps to an explicit non-reasoning variant or fails |
| agent | Headless behavior |
|---|---|
| Codex | --sandbox read-only (no file writes) |
| Claude Code | --tools "" (no tools; text-in/text-out only). The sdk transport keeps the same posture: tools: [] and permissionMode: "dontAsk". agent.tools opts in to tools without ever waiting for approval (see Tool use). The prompt is passed after -- |
| Agy | --dangerously-skip-permissions and --mode accept-edits so non-interactive --print can run tools and write files (e.g. image generation). --print-timeout matches the run timeoutMs. If a tool is still auto-denied, Agy may exit 0 with an empty stdout and a stderr notice; that is treated as a failed run. The acp transport keeps the same posture: sessions select an edit-capable mode (accept-edits/auto_edit/yolo) and session/request_permission is auto-approved. |
| Grok | No special sandbox flags (grok --single headless runs never pause for tool prompts). The acp transport keeps the same posture: grok agent runs with --always-approve and session/request_permission is auto-approved |
| Devin | --print --respect-workspace-trust false --permission-mode auto (read-only tools auto-approved; no file writes). The prompt is passed after -- |
Model candidates are read from a shared configuration file:
~/.config/headless-core/models.jsonTo use a different path:
HEADLESS_CORE_MODELS_PATH=./example/models.json npm run exampleInitialization:
headless-core models initInspect candidates in the current environment:
headless-core models inspectinspect outputs JSON to stdout. It does not overwrite the configuration file.
headless-core models init
headless-core models inspect
headless-core usage [agent...] [--json] # remaining plan usage per agentTo run the CLI from a source checkout, build first and invoke dist/cli.js (the headless-core bin points to it):
npm run build # required: the bin entry points to dist/cli.js
node dist/cli.js models inspect # inspects all providers and prints JSON to stdout- example/README.md: Minimal chat UI with a model selector
- example/app.js: Browser-side UI
- example/server.mjs: Execution example from a Node.js server
- example/models.sample.json: Models config sample
- Node.js 20+
- The corresponding Agent CLI must be installed in the local environment
- For the claude
sdktransport:@anthropic-ai/claude-agent-sdk(optional peer dependency) - For the agy
acptransport: the officialagy_acp_serverbinary (see Installingagy_acp_server) - macOS / Linux recommended
- Windows is not verified
- Does not include database persistence or a Web API server; session conversations live only inside the running agent process and are dropped on
reset()/close()/process exit - Automatic retries are not performed. Implement in
onFallbackif needed - Provider-specific authentication, billing, and rate limits depend on the settings of each CLI
- The Agent CLI may read and write local files
- The prompt, intermediate output, final result, and error details may remain in the application log
headless_core runs local AI Agent CLIs. For production use, consider the following:
- Restrict
cwdto the minimum necessary directory - Do not include secrets, API keys, or personal information in prompts or logs
- If untrusted input is included in prompts, treat it assuming prompt injection is possible
- For tasks involving file modifications, save the state (e.g., using Git) before execution
- Define post-processing after timeout, abort, or fallback on the application side
npm install
npm run build
npm testTo run all checks:
npm run checksrc/ TypeScript source
test/ Vitest tests
example/ Local demo app
docs/ Design notes and referencesMIT. See LICENSE.