Nsys Cortex

HTTP API Reference

All endpoints are prefixed with /api/v1/cortex/. Start the server with npm run start (default port: 3100, configurable via NSYS_CORTEX_PORT).

Every response includes the X-Nsys-Cortex-Version and X-Request-Id headers. Request bodies are validated with Zod schemas; invalid requests return 400 with detailed error messages.

Network binding & authentication

By default the server binds 127.0.0.1. To listen on a public interface you must:

  1. Set NSYS_CORTEX_API_HOST=0.0.0.0 (or a specific interface IP), and
  2. Set NSYS_CORTEX_API_TOKEN to a strong random value (e.g. openssl rand -hex 32).

The server refuses to bind anything other than loopback without a token. When a token is configured, every request to /api/ must include Authorization: Bearer <token> — missing or wrong tokens return:

{ "error": { "code": "UNAUTHORIZED", "requestId": "ctx-..." } }

Internal-error responses also include only a code and request id; the original error message is logged with the request id for grep-correlation but never returned (provider SDK errors can leak Authorization headers in their messages).

{ "error": { "code": "INTERNAL_ERROR", "requestId": "ctx-..." } }

:agentId route parameters are validated like the create body ([A-Za-z0-9_-]{1,64}): any other value — .., a trailing slash, a space — is 400 VALIDATION_ERROR before any lookup, so a crafted id never reaches the overlay store's filesystem.

Pass your own request id via X-Request-Id to correlate end-to-end traces.

POST /sessions/:sessionId/message answers 200 with stopReason: "end_turn" for a completed run and stopReason: "max_turns" when the run stopped at the caller's maxTurns ceiling with the model still working (response is what it said so far — an outcome the caller asked for, not a failure). It answers 502 when the agent run failed (provider auth/network error, timeout, abort, internal invariant) — previously this was a 200 with an empty or truncated response, indistinguishable from a complete answer. The detail is logged under the request id; the client gets the code, the session/run ids, and — if the model had already produced text before failing — that text as partialResponse, never as response:

{ "error": { "code": "RUN_ERROR", "requestId": "ctx-..." }, "sessionId": "ctx-...", "runId": "run-...", "partialResponse": "…" }

(The streaming /run route emits the same shape as an event: error frame and still closes the stream with run_complete; a run stopped at the caller's maxTurns emits event: max_turns instead of an error frame.)

Any command against a neuron whose agent has died (its state is stopped) — exec, tool, install, /message, /run, … — returns 409 NOT_RUNNING; re-create it with POST /neurons (which replaces a stopped neuron — in firecracker mode that is also what frees the slot, tap and chroot it still holds):

{ "error": { "code": "NOT_RUNNING", "requestId": "ctx-..." } }

POST /neurons for an id that already exists — or whose creation is still in flight — returns 409:

{ "error": { "code": "CONFLICT", "requestId": "ctx-..." } }

POST /neurons for an id whose neuron is running under another live process — another CLI, or a CLI's view of this very server — returns 409 with OWNED_ELSEWHERE; send the commands through that process, or DELETE the neuron first (destroy works from any process):

{ "error": { "code": "OWNED_ELSEWHERE", "requestId": "ctx-..." } }

An operation the server's mode does not support — today POST /neurons/:agentId/snapshot in every mode — returns 501:

{ "error": { "code": "NOT_SUPPORTED", "requestId": "ctx-..." } }

In firecracker mode, POST /neurons returns 503 when the host's neuron ceiling (NSYS_CORTEX_MAX_NEURONS) or the per-neuron uid/tap/subnet pool is exhausted — expected backpressure, not a fault; retry later:

{ "error": { "code": "CAPACITY", "requestId": "ctx-..." } }

Rate limiting

All /api/ routes are rate-limited per client IP: 600 requests / 15 min by default, with a tighter 60 / 15 min budget on the two agent-loop routes (POST /sessions/:sessionId/message and /run), which can each spend real LLM tokens. Over-budget requests return 429 with the standard envelope and RateLimit-* headers:

{ "error": { "code": "RATE_LIMITED", "requestId": "ctx-..." } }

Tune via NSYS_CORTEX_RATE_LIMIT, NSYS_CORTEX_RATE_LIMIT_LLM, NSYS_CORTEX_RATE_LIMIT_WINDOW_MS (set a limit to 0 to disable that tier). Behind a reverse proxy, configure Express trust proxy so per-IP buckets aren't shared across all clients. Limits are mounted before auth, so token brute-force attempts consume the budget too.

The limiter is in-memory, per process: a multi-instance deployment gets N× the effective limit and counters reset on restart. That's fine for the single-process default; if you run the API horizontally, front it with a shared store (e.g. express-rate-limit's Redis store) or enforce limits at the load balancer.

Tool preset

Agent-loop calls (POST /sessions/:sessionId/message and /run) default to the coding tool preset (read/write/edit/bash). Set NSYS_CORTEX_API_TOOL_PRESET=admin to expose the full tool set (process_*, apply_patch, elevated, etc.); readonly is the preset without bash. Options: coding, readonly, admin, minimal.

Configured MCP servers load once at server startup and their tools (mcp__<name>__<tool>) are added on top of whichever preset is active for every new session. Sessions created before an MCP server finishes connecting run without its tools. Above the defer threshold (NSYS_CORTEX_MCP_DEFER_THRESHOLD, default 10) each session starts with a single mcp_find_tools meta-tool instead, and the agent activates only the MCP tools it needs — per-session, so one session's activations don't bloat another's context.

Endpoints

Method Path Body Description Status
POST /neurons { agentId, vcpuCount?, memSizeMib? } Create a neuron 201
GET /neurons — List all neurons 200
GET /neurons/:agentId — Get neuron state 200 / 404
POST /neurons/:agentId/exec { code, language?, timeout? } Execute command 200
POST /neurons/:agentId/install { package, via? } Install package — same command line as the CLI (buildInstallCommand()); 400 VALIDATION_ERROR with the reason as message when the manager cannot work in the server's mode (apt on the Alpine neuron image) or the name is malformed 200 / 400
POST /neurons/:agentId/files { path, content, encoding? } Write file — a relative path resolves against the neuron's workspace (overlay dir in dev mode, /home/agent/workspace in a VM); absolute paths must stay inside it 201
GET /neurons/:agentId/files?path=... — Read file (same path rule); a missing file is 200 with success: false 200
POST /neurons/:agentId/snapshot — Create snapshot — refused in every mode today with 501 NOT_SUPPORTED: in dev and docker mode the overlay directory is the persistent state (copy it to back it up); in firecracker mode snapshot/restore are not yet supported under the jailer — see Security Posture 501
DELETE /neurons/:agentId?wipe=true — Destroy neuron (a stopped one with an overlay included; wipe=true removes the overlay); 404 for an id with neither 204 / 404
POST /neurons/:agentId/tool { tool, params, sessionId?, timeout? } Execute a tool in the neuron 200
GET /neurons/:agentId/tools — The tools a session can call — the configured preset (NSYS_CORTEX_API_TOOL_PRESET) under the standard policy's effect-class cap (ADR-011): { tools: [{ name, effectClass, idempotency?, version? }] } 200
POST /sessions { agentId, name?, model?, tools? } Create a session 201
GET /sessions — List all sessions 200
GET /sessions/:sessionId?history=N — Get one session (+ last N turns) 200 / 404
DELETE /sessions/:sessionId — Delete a session + transcript 200 / 404
POST /sessions/:sessionId/message { message, model?, maxTurns?, timeout?, responseFormat?, images? } Send message and get response 200
POST /sessions/:sessionId/run { message, model?, maxTurns?, timeout?, responseFormat?, images? } Send message with SSE streaming 200 (SSE)
GET /sessions/:sessionId/events ?after=<seq> or Last-Event-ID The session's event record, replayed then followed live (ADR-014) 200 (SSE) / 404
GET /sessions/:sessionId/approvals — The approval requests still waiting (ADR-012) 200 / 404
POST /sessions/:sessionId/approvals/:approvalId { decision: "allow" | "deny", reason? } Decide one 200 / 404 / 409
GET /health — System health 200

A neuron's createdAt is the time its agent was started when the host still has its state file; a neuron discovery finds through its overlay directory alone (stopped, or running with a state file lost to a crash) carries the directory's creation time (its birth time where the filesystem reports one, else its ctime) — never 1970-01-01T00:00:00.000Z as before.

Example Usage

# Start the API server
npm run start

# Create a neuron (no token required when binding 127.0.0.1)
curl -X POST http://localhost:3100/api/v1/cortex/neurons \
  -H "Content-Type: application/json" \
  -d '{"agentId": "my-neuron"}'

# With auth (required when binding a non-loopback host)
curl -X POST http://localhost:3100/api/v1/cortex/neurons \
  -H "Authorization: Bearer ${NSYS_CORTEX_API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"agentId": "my-neuron"}'

# Health — `active*` counts running neurons and active (non-idle) sessions,
# `total*` everything listed / every unexpired session:
#   { "status": "ok", "version": "0.2.0", "activeNeurons": 1, "totalNeurons": 2,
#     "activeSessions": 0, "totalSessions": 3, "uptime": 42.1 }

# Execute a command
curl -X POST http://localhost:3100/api/v1/cortex/neurons/my-neuron/exec \
  -H "Content-Type: application/json" \
  -d '{"code": "echo hello from cortex", "language": "bash"}'

# Check health
curl http://localhost:3100/api/v1/cortex/health

# Execute a tool
curl -X POST http://localhost:3100/api/v1/cortex/neurons/my-neuron/tool \
  -H "Content-Type: application/json" \
  -d '{"tool": "bash", "params": {"command": "ls -la"}}'

# List available tools
curl http://localhost:3100/api/v1/cortex/neurons/my-neuron/tools

# Create a session
curl -X POST http://localhost:3100/api/v1/cortex/sessions \
  -H "Content-Type: application/json" \
  -d '{"agentId": "my-neuron", "name": "worker"}'

# Fetch one session (metadata only). The session object includes a
# cumulative `usage` field (input/output/cacheRead/cacheCreation/reasoning
# tokens + turns + runs) accumulated across every run and persisted, so it
# reflects the whole session lifetime — the same numbers the CLI `/cost` shows.
curl http://localhost:3100/api/v1/cortex/sessions/ses-abc123

# Fetch a session with its last 20 transcript entries
curl "http://localhost:3100/api/v1/cortex/sessions/ses-abc123?history=20"

# Delete a session and its transcript
curl -X DELETE http://localhost:3100/api/v1/cortex/sessions/ses-abc123

# Send message and get full response (non-streaming)
curl -X POST http://localhost:3100/api/v1/cortex/sessions/ses-abc123/message \
  -H "Content-Type: application/json" \
  -d '{"message": "List the files in the workspace"}'

# Structured output: constrain the final response to a JSON schema
# (name optional; strict defaults true; schema capped at 32KB serialized —
#  see docs/agent-runtime.md#structured-outputs for per-provider behavior)
curl -X POST http://localhost:3100/api/v1/cortex/sessions/ses-abc123/message \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Summarize the workspace as JSON",
    "responseFormat": {
      "name": "summary",
      "schema": {
        "type": "object",
        "properties": { "fileCount": {"type": "integer"}, "languages": {"type": "array", "items": {"type": "string"}} },
        "required": ["fileCount", "languages"],
        "additionalProperties": false
      }
    }
  }'

# Vision input: attach base64 images to the message (max 4, ~5MB each)
curl -X POST http://localhost:3100/api/v1/cortex/sessions/ses-abc123/message \
  -H "Content-Type: application/json" \
  -d "{\"message\": \"what is in this screenshot?\", \"images\": [{\"data\": \"$(base64 -i shot.png)\", \"mimeType\": \"image/png\"}]}"

# Send message with SSE streaming
curl -N -X POST http://localhost:3100/api/v1/cortex/sessions/ses-abc123/run \
  -H "Content-Type: application/json" \
  -d '{"message": "Create a hello.txt file", "model": "claude-sonnet-4-6"}'

SSE Event Format

The /sessions/:sessionId/run endpoint returns Server-Sent Events. Each event:

event: text_delta
data: {"type":"text_delta","runId":"run-abc","sessionId":"ses-123","text":"Let me create"}

event: tool_call_start
data: {"type":"tool_call_start","runId":"run-abc","toolCallId":"tc-1","toolName":"write"}

event: tool_call_end
data: {"type":"tool_call_end","runId":"run-abc","toolCallId":"tc-1","toolName":"write","durationMs":42}

event: run_complete
data: {"type":"run_complete","runId":"run-abc","totalTurns":2,"totalUsage":{"inputTokens":5200,"outputTokens":1800}}

Error events are sanitized. If a run fails, the stream emits a generic error frame — never the raw error text, which could echo provider SDK details such as an x-api-key/Authorization header. The full detail is logged server-side, keyed by the request ID:

event: error
data: {"type":"error","code":"RUN_ERROR","requestId":"req-abc"}

Sequence numbers. Every recorded event — everything but text_delta / thinking_delta — carries its per-session sequence number as the frame's id: and as seq in the data (ADR-014, #14), so a client that loses the stream can resume on the event stream below from the last id it saw. Deltas carry no id.

Flow control. SSE writes honor socket backpressure: when a client reads slowly, the server pauses the agent-loop generator until the socket drains instead of buffering unboundedly in memory — so a slow consumer throttles the run rather than the server absorbing it. Disconnecting mid-stream aborts the run entirely (the request's close event cancels the in-flight LLM call and tool execution), so tokens stop being spent for a client that's gone.

Event stream

GET /sessions/:sessionId/events is the session's event record as SSE (ADR-014, #14): every run's events are recorded per session with a sequence number (deltas stream live and are coalesced into one text — and one thinking, when any — event per turn), and this route replays the record and then follows the session live, for as long as the client stays, for several viewers at once, independently of the request that started a run.

id: 7
event: text
data: {"type":"text","seq":7,"runId":"run-abc","sessionId":"ses-123","turnNumber":1,"text":"Let me create the file."}

event: text_delta
data: {"type":"text_delta","runId":"run-def","sessionId":"ses-123","text":"Next"}

Approvals

The agent loop's approval gate (ADR-012, #16) reaches the API through two routes and the event stream. A tool whose effect class agent.approval.requireFor lists (default destructive), a tool whose policy requires approval, or elevated at the ask level does not run until an explicit allow:

Validation Constraints

Field Constraints
agentId 1-64 chars, alphanumeric + hyphens/underscores
vcpuCount 1-8, integer
memSizeMib 128-8192, integer
language bash, node, python (default: bash)
timeout 1-300000 ms
via apt, npm, pip (default: npm)
encoding utf-8, base64
tool Required string (tool name)
params Required object (tool-specific parameters)
responseFormat.name Optional; 1-64 chars, [a-zA-Z0-9_-]
responseFormat.schema Required object; ≤32KB serialized
responseFormat.strict Optional boolean (default true)
sessionId Optional string (session context for tool call)
name Optional string (session display name)
model Optional string (model for session)
tools Optional string array (tool names for session)
message Required string, min 1 char