Skip to content

Headless Coding Agent API

View as Markdown

Nuabase can run get() and list() calls headless through a coding agent CLI already installed and logged in on the user’s machine: Claude Code (claude -p), Codex (codex exec), or Pi (pi --mode json). Calls use the agent’s existing login, so no API key is required and usage is billed to the subscription the user already has.

Use it for quick, typed judgements your app makes on its own. dbu6, a bookkeeping app built on Nuabase, uses it to pick an account for each imported bank transaction, to draft a chart of accounts from a sentence the user typed, and to check that each of the agent’s models answers before relying on it. It suits local-first and desktop apps, developer tools, and CLIs. Anyone with Claude Code, Codex, or Pi installed can run them with no .env file and no keys to provision.

When a job needs the agent to work in the user’s project rather than return one answer, hand it over as an Interactive Coding Agent Session.

Local agent helpers live under a separate import path, nuabase/local-agent, because spawning a CLI requires Node.js built-ins. Keeping them separate lets the main nuabase entry point load in edge runtimes such as Cloudflare Workers and Vercel Edge.

import { Nua } from 'nuabase';
import { localAgent, findLocalAgent, detectLocalAgents } from 'nuabase/local-agent';
// Pick the first agent that is installed and logged in (Claude Code, then Codex, then Pi)
const auto = Nua.direct({ localAgent: await findLocalAgent() });
// Or configure a specific agent and model
const claude = Nua.direct({
localAgent: localAgent({ agent: 'claude-code', model: 'haiku' }),
});

Checks each supported CLI’s version and login status, without making a model call, and resolves to the first one that is ready. Rejects if none is. Takes the same options as localAgent() except agent and binaryPath. If you pass model, it must be a name that whichever agent is found accepts, so it is usually better left unset here.

Configures a named agent. Throws immediately if the CLI binary is not found on PATH. A missing login is reported as success: false on the first call.

Both functions resolve the binary once, when they are called. If you install or log in to an agent later, create a new instance.

Reports which agents are installed and logged in, in preference order. Makes no model calls.

const agents = await detectLocalAgents();

On a machine with all three installed:

[
{
agent: 'claude-code',
installed: true,
loggedIn: true,
binaryPath: '/Users/you/.local/bin/claude',
version: '2.1.308 (Claude Code)',
detail: 'claude.ai'
},
{
agent: 'codex',
installed: true,
loggedIn: true,
binaryPath: '/opt/homebrew/bin/codex',
version: 'codex-cli 0.154.0',
detail: 'Logged in using ChatGPT'
},
{
agent: 'pi',
installed: true,
loggedIn: true,
binaryPath: '/Users/you/.npm-global/bin/pi',
version: '0.87.1',
detail: 'installed; auth is verified on the first call'
}
]

An agent that isn’t installed comes back as { agent, installed: false, loggedIn: false }. Pi has no command that reports its login, so for Pi loggedIn: true only means it is installed, as detail says. A Pi whose provider rejects its credentials fails on the first call instead, without retrying, for example with reason '401: {"message":"User not found.","code":401}' from OpenRouter.

OptionDefaultDescription
agentRequired for localAgent()'claude-code', 'codex', or 'pi'.
binaryPathLooked up on PATHPath to the CLI binary (localAgent() only). On Windows, point to a native executable; .cmd shims are not supported.
modelThe agent’s defaultThe agent’s own model name, used for calls that name no model. See Choosing a model.
effortThe agent’s default'low', 'medium', or 'high': how hard the model thinks before it answers. Passed to the CLI’s own setting: Claude Code --effort, Codex model_reasoning_effort, Pi --thinking.
timeoutMs180000Maximum duration of one call, in milliseconds.
concurrency2Maximum number of agent processes running at once. Further calls wait for a free slot.
auth'subscription''subscription' removes ANTHROPIC_API_KEY, OPENAI_API_KEY, and similar variables from the agent’s environment, so calls use your logged-in plan. 'inherit' keeps them.
loggerconsoleAn object with debug, info, warn, and error methods, for call and retry logs. The default writes an [INFO] entry with console.info when each call starts and completes, and [WARN] with console.warn before each retry.

Set the agent’s default model with the model option. Each agent takes its own model names:

  • Claude Code: 'haiku', 'sonnet', 'opus'
  • Codex: Codex’s own model names
  • Pi: 'provider/id', for example 'openrouter/z-ai/glm-5.3-flash'

Per call, you can pass a model alias. Claude Code maps { alias: 'haiku' }, { alias: 'sonnet' }, and { alias: 'opus' } to its own models. Codex and Pi have no alias equivalents.

const result = await nua.get('Summarize this diff', {
input: diff,
output: { name: 'summary', schema: z.string() },
model: { alias: 'sonnet' },
});

A provider model such as { provider: 'openrouter', model: '...' }, or an alias the agent has no equivalent for, can’t run on a local agent. The call returns success: false with attempts: 0 and nothing is spawned. On Claude Code, the reasons are:

Model alias "fast" has no claude-code equivalent
claude-code takes model aliases, not provider models. Set the agent's own model with localAgent({ model }) or with().

agent.with({ model, effort, timeoutMs }) returns the same agent with other call options. It shares the original’s executable, environment, logger, and concurrency limit, so one localAgent() per CLI can serve calls on several of its models without running more processes at once than the limit allows.

const codex = localAgent({ agent: 'codex', concurrency: 2 });
const quick = Nua.direct({ localAgent: codex.with({ model: 'gpt-5.6-terra', effort: 'low' }) });
const careful = Nua.direct({ localAgent: codex.with({ model: 'gpt-5.6-sol', timeoutMs: 300_000 }) });
// Both clients share the same limit of two processes.

Options you leave out keep the agent’s values. A model or effort given as undefined uses the CLI’s default.

A common split is a cheaper model at low effort for bulk work such as classifying rows, and the most capable model at its default effort for a single careful answer.

Some accounts can’t use every model an agent offers. Before relying on one, ask it something trivial with a short timeout, and show the user reason if it fails:

const nua = Nua.direct({ localAgent: agent.with({ model: 'opus', timeoutMs: 60_000 }) });
const reply = await nua.get('Reply with the single word OK.');
if (!reply.success) {
markUnavailable('opus', reply.reason);
}

For a model the account can’t use, reason is the agent’s own message. From Claude Code:

There's an issue with the selected model (claude-nonexistent-9). It may not exist or you may not have access to it. Run --model to pick a different model.
  • Sandboxed: Every call spawns the CLI in a fresh, empty temporary directory, with all tools, project hooks and settings, and MCP servers disabled. The agent answers from the prompt alone.
  • Native or in-prompt schema:
    • Claude Code uses its native schema flag.
    • Codex uses its native schema flag when the schema is compatible with OpenAI strict mode. Schemas with optional properties or open-ended objects are described in the prompt instead.
    • Pi has no schema flag, so the schema is always described in the prompt.
    • If an agent rejects the schema flag, Nuabase retries with the schema in the prompt.

The answer is parsed with your Zod schema either way. result.meta.schemaEnforcement tells you which path was used.

This is what a nua.get() call on localAgent({ agent: 'claude-code', model: 'haiku' }) spawned, recorded with a wrapper script as binaryPath. ANTHROPIC_API_KEY was set in the calling process:

cwd: /private/var/folders/d5/…/T/nua-local-agent-S9M2nu (empty)
argv: claude -p --output-format json --tools '' --setting-sources '' --strict-mcp-config
--no-session-persistence
--system-prompt 'You are a data transformation function called by a program. You have no tools. Follow the instructions in the user message exactly and reply only with the requested JSON.'
--model haiku
--json-schema '{"type":"object","properties":{"value":{"type":"object","properties":{"sentiment":{"type":"string","enum":["positive","neutral","negative"]},"confidence":{"type":"number","minimum":0,"maximum":1}},"required":["sentiment","confidence"]}},"required":["value"],"additionalProperties":false}'
env: ANTHROPIC_API_KEY not set

The prompt goes in on stdin. Pi has no schema flag, so its call carries no schema in argv, and the schema is described in the prompt instead:

argv: pi --mode json --no-session --no-tools --no-builtin-tools --no-extensions --no-skills
--no-prompt-templates --no-themes --no-context-files
--system-prompt 'You are a data transformation function called by a program. You have no tools. Follow the instructions in the user message exactly and reply only with the requested JSON.'
--offline

See Prompt construction for the stdin a Pi call receives.

A local agent result looks like any other, with agent details in meta:

if (result.success) {
result.model; // "claude-code:claude-haiku-5-5"
result.meta.engine; // "local-agent"
result.meta.agent; // "claude-code"
result.meta.model; // "claude-haiku-5-5"
result.meta.costUsdEstimate; // 0.0003919
}

model is the model the agent reports, so a request for 'haiku' comes back as the full model name. When the agent doesn’t report one and none was requested, it is "<agent>:default". A Pi call configured for DeepSeek came back as "pi:deepseek-flash".

costUsdEstimate is what the call would have cost at API prices. It doesn’t reflect subscription billing.

Local agent calls count against your personal subscription’s usage limits. Check each vendor’s terms of service before exposing local agent calls to external end users.