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
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:
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
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 |
--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-remoteand 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/healthneedsAuthorization: Bearer <token>, compared in constant time. - On loopback binds the
Hostheader must name a loopback address, which defeats DNS rebinding from web pages. - A session's working folder must be inside one of the
--rootfolders (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:
{ "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.
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
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.
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
initializewithauth.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 infull-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
remotePolicyPublicKeyor a key inembed.hostPolicyPublicKeysin the local managed settings; unsigned bundles are rejected unless that file setsembed.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,/loginand switching providers are refused. - Locked settings stay locked. Mode changes honour
permissions.modelocks anddisableFullAuto.
{
"remotePolicyPublicKey": "MCowBQYDK2VwAyEA…",
"embed": { "hostPolicyPublicKeys": ["MCowBQYDK2VwAyEA…"], "trustHostPolicy": false }
}
Example host
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:
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.