---
title: "Headless Coding Agent API"
description: "Run Nuabase calls through Claude Code, Codex, or Pi using the login already on your machine, with no API key."
canonical: "https://nuabase.com/docs/local-agent/"
---

> Documentation index: https://nuabase.com/llms.txt

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](https://dbu6.com), 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](/docs/local-agent/sessions.md).

## Setup

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.

```typescript
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' }),
});
```

## Functions

### `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)`

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()`

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

```typescript
const agents = await detectLocalAgents();
```

On a machine with all three installed:

```text
[
  {
    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

| 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](#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

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.

```typescript
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 `reason`s are:

```text
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().
```

## 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.

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

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:

```typescript
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:

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

- **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:

```text
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:

```text
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](/docs/how-it-works.md#prompt-construction) for the stdin a Pi call receives.

## Results

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

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

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.
