---
title: "SDK, server and embedding"
---

# SDK, server and embedding

Drive the CLI's agent from your own code in process, over the local HTTP and SSE server, or as an agent embedded in a governing host with the NI Host Protocol.

There are three ways to drive the agent from code. All of them use one event schema, the same one `--print --output-format stream-json` writes.

| | In process | Local server | Governing host |
|---|---|---|---|
| Entry point | `createAgent()` | `neuralinverse serve` and `connect()` | `neuralinverse host` and `spawnAgent()` |
| Package export | `@neuralinverse/cli/sdk` | `@neuralinverse/cli/sdk` | `@neuralinverse/cli/embed` |
| Runs | Inside your Node.js process | In a separate server process | In a child process the host controls |
| Good for | Scripts, CI tools, Node.js apps | Editor plugins, web interfaces, other languages, several clients | Apps that supply models, credentials and policy, and decide every approval |

Permission rules, modes, managed policy and organisation governance apply in all three, as in the terminal.

## In process

```ts
import { createAgent } from '@neuralinverse/cli/sdk';

const agent = await createAgent({
  cwd: '/path/to/repo',
  model: 'anthropic/claude-sonnet-5-5',
  mode: 'ask',
  // Called whenever a tool call needs approval. Without it such calls are denied.
  onPermission: async (req) => {
    console.log(`${req.toolName}: ${req.target.summary}`);
    return req.target.effect === 'write' ? { kind: 'allow_once' } : { kind: 'deny', feedback: 'read-only please' };
  },
});

try {
  const result = await agent.prompt('Fix the failing test in src/parser.ts', {
    onEvent: (e) => {
      if (e.type === 'text_delta') process.stdout.write(e.text);
    },
  });
  console.log(`\n${result.reason}, ${result.usage.outputTokens} output tokens, $${result.costUsd ?? '?'}`);
} finally {
  await agent.close();
}
```

Stream with an async iterator; leaving the loop early interrupts the prompt:

```ts
for await (const e of agent.stream('Summarize the README')) {
  if (e.type === 'text_delta') process.stdout.write(e.text);
}
```

| Option | Meaning |
|---|---|
| `cwd` | Workspace folder (default `process.cwd()`) |
| `model` | `provider/model` |
| `mode` | `ask`, `auto-edit`, `read-only`, `full-auto` |
| `settings` | Extra settings layered on the settings files |
| `configDir`, `managedSettingsPath` | Override `~/.neuralinverse` and the managed settings file (`null` disables it) |
| `onPermission(req, signal)` | Approve or deny tool calls: `allow_once`, `allow_always` with `rule` and `persist`, or `deny` |
| `askUser`, `reviewPlan` | Answer the agent's questions and plan proposals |
| `providers` | Ready-made provider instances by id |
| `enableMcp` | Start configured MCP servers (default `true`) |
| `persistSessions`, `resume` | Save the conversation (default `false`) or continue a saved one |
| `appendSystemPrompt`, `addDirs`, `env`, `onLog` | As the CLI flags of the same names |

The returned agent has `prompt()`, `stream()`, `interrupt()`, `setMode()`, `setModel()`, `messages()`, `close()` and `runtime`.

## The local server

```bash
neuralinverse serve                      # 127.0.0.1:7420, root = current folder
neuralinverse serve --port 0 --root ~/src --root ~/work --mode auto-edit
```

On start the server prints its URL and a newly generated bearer token, and writes both to `~/.neuralinverse/server.json` (mode 0600; removed on shutdown). Global flags (`--model`, `--mode`, `--allow`, `--deny`, `--config`, `--no-mcp`, `--append-system-prompt` and others) apply to every session.

| Flag | Default | Meaning |
|---|---|---|
| `--host <addr>` | `127.0.0.1` | Interface to bind |
| `--port <n>` | `7420` | `0` picks a free port |
| `--token <t>` | `NEURALINVERSE_SERVER_TOKEN`, else random | At least 16 characters |
| `--allow-remote` | off | Required for non-loopback addresses, together with an explicit token |
| `--cors <origin>` | off | Allow browser requests from exactly this origin |
| `--root <dir>` | current folder | Folders sessions may work in (repeatable) |
| `--permission-timeout <s>` | `300` | Unanswered approvals are denied after this long |
| `--max-sessions <n>` | `32` | Concurrent sessions |
| `--web` | off | Also serve the [browser interface](https://neuralinverse.com/docs/cli/web/) |
| `--json-ready` | off | Once listening, print one JSON line `{"type":"ready","url","host","port","pid","version","api_version","roots"}` (plus `token` when generated) and do not write `server.json` |
| `--exit-on-stdin-close` | off | Shut down when standard input closes |

A tool that starts the server typically runs `neuralinverse serve --port 0 --json-ready --exit-on-stdin-close` with the token in `NEURALINVERSE_SERVER_TOKEN` (keeping it out of process listings) and reads the first line of output. The VS Code extension works this way. `SIGINT` and `SIGTERM` stop the server gracefully.

### Security

- Loopback only, unless `--allow-remote` **and** an explicit token are given. There is no TLS: put a TLS-terminating proxy in front of a remote server.
- Every request except `GET /v1/health` needs `Authorization: Bearer <token>`, compared in constant time.
- On loopback binds the `Host` header must name a loopback address, which defeats DNS rebinding from web pages.
- A session's working folder must be inside one of the `--root` folders (symbolic links are resolved first).
- Request bodies are limited (1 MiB; prompts 25 MiB) and validated; at most 16 prompts can wait in a session's queue.
- Anyone with the token can run tools with your user's rights. Treat the token like a password.

### Endpoints

Bodies are JSON. Errors are `{"error": {"code": "...", "message": "..."}}` with a matching status, for example `400 validation_error`, `401 unauthorized`, `403 cwd_outside_roots`, `404 session_not_found`, `409 session_busy`, `413 payload_too_large`, `415 unsupported_media_type`, `429 too_many_sessions`.

| Method and path | Body | Response |
|---|---|---|
| `GET /v1/health` (no auth) | | `{status: "ok"}` |
| `GET /v1/info[?live=1]` | | Version, `api_version`, roots, limits, providers, `default_model`, models |
| `POST /v1/sessions` | `{cwd?, model?, mode?, resume?}` | `201` session info |
| `GET /v1/sessions` | | `{sessions: [...]}` |
| `GET /v1/stored-sessions[?cwd=]` | | Resumable sessions of a workspace |
| `GET /v1/sessions/:id` | | Session state, model, mode, title, usage, queue and pending requests, `last_event_id` |
| `DELETE /v1/sessions/:id` | | Closes the session |
| `POST /v1/sessions/:id/prompt` | `{text, images?, queue?}` | `202 {turn_id, queued, position?}`; `409 session_busy` while a prompt runs unless `queue: true` |
| `POST /v1/sessions/:id/interrupt` | `{clear_queue?}` | `{interrupted, cleared_queue}` |
| `GET /v1/sessions/:id/events` | | SSE stream |
| `GET /v1/sessions/:id/messages` | | `{messages: [...]}` |
| `POST /v1/sessions/:id/permissions/:requestId` | An answer (below) | Answers a permission, question or plan request (`/questions/…`, `/plans/…` are aliases) |
| `POST /v1/sessions/:id/mode` | `{mode}` | `{mode}` |
| `POST /v1/sessions/:id/model` | `{model}` | `{model}` (idle sessions only) |
| `POST /v1/sessions/:id/compact` | `{instructions?}` | `{before_tokens, after_tokens}` |
| `GET /v1/sessions/:id/checkpoints` | | `{checkpoints: [...]}` |
| `POST /v1/sessions/:id/rewind` | `{checkpoint_id, files?, conversation?}` | `{restored_files, message_count}` |
| `GET /v1/sessions/:id/files[?q=&limit=]` | | Fuzzy file search in the session's workspace (`limit` at most 500, default 50) |
| `GET /v1/fs/dirs[?path=]` | | Sub-folders of a folder inside the roots |

### The event stream

`GET /v1/sessions/:id/events` is a server-sent events stream. Every event has an `id:` line (a counter per session), an `event:` line with its type, and a JSON `data:` line. A heartbeat comment is sent every 15 seconds. The last 1000 events of each session are kept: reconnect with `Last-Event-ID` (or `?last_event_id=`) to receive everything after that id; if some was dropped, a `replay_gap` event comes first.

Agent events: `turn_start`, `text_delta`, `thinking_delta`, `assistant_message`, `tool_call`, `tool_progress`, `tool_result`, `tool_denied`, `retry`, `compacted`, `usage`, `notice`, `task_update`, `done` and others. Server events:

| Event | Fields |
|---|---|
| `prompt_queued` | `position` |
| `prompt_started` | |
| `prompt_finished` | `reason` (`end_turn`, `max_turns`, `max_tokens`, `refusal`, `aborted`, `error`, `budget`), `error?` |
| `permission_request` | `request_id, call_id, tool, summary, preview?, suggested_rule, effect, input, outside_workspace?, expires_at` |
| `question_request` | `request_id, question, options, multi_select, allow_other, expires_at` |
| `plan_request` | `request_id, plan, expires_at` |
| `request_resolved` | `request_id, kind, outcome` (`answered`, `timeout`, `cancelled`) |
| `mode_changed`, `model_changed` | `mode`, `model` |
| `session_closed` | The stream ends after it |

Answers to `POST …/permissions/:requestId`:

```jsonc
{ "answer": "allow_once" }                                         // permission
{ "answer": "allow_always", "rule": "run_shell(npm test *)", "persist": "session" }
{ "answer": "deny", "feedback": "use the staging database instead" }
{ "answer": { "answers": ["Postgres"], "other": "with pgvector" } }  // question
{ "answer": { "dismissed": true } }
{ "answer": { "approved": true, "mode": "auto-edit" } }            // plan
{ "answer": { "approved": false, "feedback": "split it in two PRs" } }
```

Unanswered requests are denied, dismissed or rejected after `--permission-timeout`, and when the prompt is interrupted.

```bash
TOKEN=$(jq -r .token ~/.neuralinverse/server.json)
URL=http://127.0.0.1:7420
H="authorization: Bearer $TOKEN"

ID=$(curl -s -H "$H" -H 'content-type: application/json' -d '{"mode":"ask"}' $URL/v1/sessions | jq -r .id)
curl -sN -H "$H" $URL/v1/sessions/$ID/events &
curl -s -H "$H" -H 'content-type: application/json' -d '{"text":"List the TODOs"}' $URL/v1/sessions/$ID/prompt
```

### Remote client

```ts
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { connect } from '@neuralinverse/cli/sdk';

const { url, token } = JSON.parse(readFileSync(`${homedir()}/.neuralinverse/server.json`, 'utf8'));
const client = connect({ url, token });

const session = await client.createSession({ cwd: '/home/me/src/app', mode: 'ask' });
const result = await session.run('Add input validation to the signup form', {
  onEvent: (e) => {
    if (e.type === 'text_delta') process.stdout.write(e.text);
  },
  onPermission: (req) => (req.effect === 'write' ? { answer: 'allow_once' } : { answer: 'deny' }),
  onQuestion: (q) => ({ answer: { answers: [q.options[0]!.label] } }),
});
console.log(`\n${result.reason}`);
await session.close();
```

`run()` subscribes to the event stream, sends the prompt, answers requests with your callbacks (without them, approvals are denied, questions dismissed and plans rejected) and resolves when the prompt finishes. `events()` reconnects after network errors, resuming with `Last-Event-ID`. Failures surface as `ApiError` with `status` and `code`. `startServer(options)` and `startAcpAgent(options)` are exported too, for hosts that serve their own runtimes.

## Embedding in a governing host

`neuralinverse host` turns the CLI into an agent that a host application drives: the Editor's shared agent host first, and any editor or app after it. The host sees every action, decides approvals, supplies models and credentials, and can push the organisation's policy.

**Maturity.** The CLI side is ready and tested with a conformance kit. The adapter that registers the CLI as an agent in the Editor's shared agent host is drafted but not yet part of the Editor; file-system, terminal, question and attachment wiring on the Editor side is not done yet.

```text
neuralinverse host [--stdio]                                  # default
neuralinverse host --socket <path> --token-file <file>        # or --token-fd <n>
  --grace <ms>          running turns may finish this long after the host goes away (default 10000)
  --max-sessions <n>    default 64
  --event-buffer <n>    events kept per session for replay (default 2000)
```

- **stdio:** standard output carries the protocol (JSON-RPC 2.0, one message per line); diagnostics go to standard error. The agent exits when standard input ends (after the grace period) or after `shutdown`.
- **socket:** a Unix socket created with mode 0600 in a folder you own (a named pipe on Windows). The first message of every connection must be `initialize` with `auth.token`; a wrong token, or no handshake within 10 seconds, closes the connection. The token comes from a 0600 file you own or an inherited file descriptor, never from the command line.

### Capabilities a host can declare

| Capability | The CLI then | Without it |
|---|---|---|
| `credentials.keystone` | Uses a Govern session the host supplies through `auth/token` | The CLI's own Govern sign-in |
| `models` | Registers the provider `host` (models `host/<id>`) on the host's endpoint (`anthropic`, `openai-responses` or `openai`); `exclusive` removes every other provider | User-configured providers |
| `approvals` | Sends `approval/request` for every ask; with `review: "mutations"`, also for every write, command and network call that rules or the mode would allow, including in `full-auto`; `all` adds reads | Requests wait, then time out (denied) |
| `interactions` | Sends questions and plans to the host | Questions answered "nobody available"; plans rejected |
| `policy` | Accepts `policy/push` | |
| `activity` | Sends activity events to the host | The CLI's own queue |
| `fs` | File tools read and write through the host (open editor buffers) | Disk |
| `terminal` | Foreground shell commands run in host terminals | Child processes |

### Governance in a host

- **The host sees every action.** Permission asks go to the host with the full approval card; with `approvals.review: "mutations"`, every mutating call is routed to the host first, even in `full-auto`. The host's denial is final; a blocking checks finding refuses the change whatever the answer.
- **Policy layers only tighten.** Local managed settings and the remote policy apply first. A host-pushed policy applies like a managed layer for sessions created after the push. It must carry an Ed25519 signature that verifies against `remotePolicyPublicKey` or a key in `embed.hostPolicyPublicKeys` in the local managed settings; unsigned bundles are rejected unless that file sets `embed.trustHostPolicy: true`. `remotePolicy*` keys are stripped from host bundles.
- **Models and credentials stay with the host.** With `models.exclusive`, the session can use only the host's loopback endpoint; `/provider`, `/login` and switching providers are refused.
- **Locked settings stay locked.** Mode changes honour `permissions.mode` locks and `disableFullAuto`.

```json
{
  "remotePolicyPublicKey": "MCowBQYDK2VwAyEA…",
  "embed": { "hostPolicyPublicKeys": ["MCowBQYDK2VwAyEA…"], "trustHostPolicy": false }
}
```

### Example host

```ts
import { spawnAgent } from '@neuralinverse/cli/embed';

const agent = await spawnAgent({
  clientInfo: { name: 'my-host', version: '1.0.0' },
  capabilities: {
    models: { endpoint: { kind: 'anthropic', baseUrl: 'http://127.0.0.1:4100', auth: 'token' }, exclusive: true },
    approvals: { review: 'mutations' },
  },
  handlers: {
    token: async ({ audience }) => ({ token: await myTokens.mint(audience) }),
    approvals: async ({ approval }) =>
      approval.risk === 'high' || approval.checks?.blocking ? { outcome: 'deny', reason: 'needs a human' } : { outcome: 'allow_once' },
    activity: ({ events }) => myAudit.forward(events),
  },
});

const session = await agent.newSession({ cwd: '/work/repo', mode: 'auto-edit' });
const result = await agent.run(session.id, 'Fix the failing test in src/date.ts', {
  onEvent: (e) => { if (e.type === 'text_delta') process.stdout.write(e.text); },
});
await agent.stop({ graceMs: 5000 });
```

`createHostedRuntime(options)` runs the agent inside your process with the same protocol over an in-memory stream; `connectSocket(path, { token })` connects to `--socket` mode.

### Protocol reference and versioning

The message reference is the machine-readable schema `schema/host-protocol.schema.json` in the package. Host-to-agent methods include `initialize`, `health`, `shutdown`, `policy/push`, `session/new`, `session/load`, `session/list`, `session/fork`, `session/close`, `session/prompt`, `session/cancel`, `session/set_model`, `session/set_mode`, `session/subscribe`, `session/events`, `session/answer`, `session/checkpoints`, `session/rewind`, `session/undo`, `session/tasks`, `session/usage` and `session/commands`. Agent-to-host methods are `approval/request`, `interaction/question`, `interaction/plan`, `auth/token`, `fs/*`, `terminal/*`, and the notifications `session/event` and `activity/event`.

Each session keeps its last `--event-buffer` events; subscribe with the last id you processed as `afterSeq` to replay what you missed. Application errors use code `-32000` with `data.code` (`session_not_found`, `session_busy`, `locked_by_policy`, `model_not_allowed`, `shutting_down` and others). Protocol errors: `-32002` not initialised, `-32003` wrong handshake token, `-32004` no common protocol version, `-32005` policy bundle rejected.

The protocol version is semver, now `1.0.0`. The agent accepts the same major version and answers the lower of the two; minor versions only add. A breaking change bumps the major, and the CLI keeps accepting the previous major for at least two minor releases after a new major ships.

### Conformance kit

Run the CLI's conformance scenarios (handshake, approvals, full-auto still honouring host denials, token refresh, policy push, cancel, replay after reconnect, resume after restart, clean exit) against your integration from a checkout of the CLI:

```bash
npm run embed:conformance -- --command "neuralinverse host --stdio"
npm run embed:conformance -- --command "/path/to/ide-wrapper --stdio" --only approval --verbose
```

The command must speak the protocol on standard input and output and honour `NEURALINVERSE_CONFIG_DIR` and `NEURALINVERSE_MANAGED_SETTINGS`, which the kit points at throwaway folders.

## Licence for embedding

The CLI is proprietary. You may embed it in, or ship it with, another product only as your agreement with Neural Inverse allows. Keep `LICENSE` and `NOTICE` in the package, and keep the licence headers of its files.
