Headless Coding Agent API
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 modelconst claude = Nua.direct({ localAgent: localAgent({ agent: 'claude-code', model: 'haiku' }),});Functions
Section titled “Functions”findLocalAgent(options?)
Section titled “findLocalAgent(options?)”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.
localAgent(config)
Section titled “localAgent(config)”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.
detectLocalAgents()
Section titled “detectLocalAgents()”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.
Options
Section titled “Options”| Option | Default | Description |
|---|---|---|
agent | Required for localAgent() | 'claude-code', 'codex', or 'pi'. |
binaryPath | Looked up on PATH | Path to the CLI binary (localAgent() only). On Windows, point to a native executable; .cmd shims are not supported. |
model | The agent’s default | The agent’s own model name, used for calls that name no model. See Choosing a model. |
effort | The 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. |
timeoutMs | 180000 | Maximum duration of one call, in milliseconds. |
concurrency | 2 | Maximum 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. |
logger | console | An 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. |
Choosing a model
Section titled “Choosing a model”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 equivalentclaude-code takes model aliases, not provider models. Set the agent's own model with localAgent({ model }) or with().Several models on one agent
Section titled “Several models on one agent”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.
Checking a model answers
Section titled “Checking a model answers”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.How calls run
Section titled “How calls run”- 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 setThe 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.' --offlineSee Prompt construction for the stdin a Pi call receives.
Results
Section titled “Results”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.
Usage limits and terms
Section titled “Usage limits and terms”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.