Architecture
Overview
┌────────────────────────────────────────────────────────────┐
│ Cortex Host │
│ │
│ ┌─────────┐ ┌──────────┐ ┌────────────────────┐ │
│ │ CLI │ │ HTTP API │ │ Agent Toolkit │ │
│ │ │ │ :3100 │ │ (27 tools, │ │
│ │ │ │ │ │ policy engine) │ │
│ └────┬────┘ └────┬─────┘ └──┬──────────────┬──┘ │
│ │ │ │ │ │
│ │ ┌────┴────┐ ┌───┴─────────┐ ┌─┴─────────┐ │
│ │ │ Session │ │ NeuronProxy │ │ System │ │
│ │ │ Manager │ │ │ │ Prompt │ │
│ │ └─────────┘ └───┬─────────┘ │ Builder │ │
│ │ │ └───────────┘ │
│ └───────────┬────────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ Sandbox │ │
│ │ Manager │ │
│ └──────┬──────┘ │
│ ┌──────┴───────┐ │
│ │ Vsock Server │ │
│ └──────┬───────┘ │
│ │ Unix Socket / vsock │
├───────────────────┼────────────────────────────────────────┤
│ ┌──────┴───────┐ │
│ │ Vsock Client │ │
│ └──────┬───────┘ │
│ │ │
│ ┌─────────┐ ┌───┴──────┐ ┌────────────┐ │
│ │ Health │ │ Executor │ │ Overlay │ │
│ │ Monitor │ │ │ │ Filesystem │ │
│ └─────────┘ └──────────┘ └────────────┘ │
│ │
│ Neuron (MicroVM / Process) │
└────────────────────────────────────────────────────────────┘
Package Structure
| Package | Scope | Description |
|---|---|---|
packages/cortex-shared |
@nsys-cortex/shared |
Types, Zod schemas, protocol constants, logger (file output + level filtering + secret redaction), config loader, env scrubber, symlink-safe path utilities, ID generation |
packages/cortex-agent |
@nsys-cortex/agent |
Neuron-side agent: command execution, file I/O with realpath-based validation and atomic writes, health. Bundled with esbuild. |
packages/cortex-host |
@nsys-cortex/host |
Host orchestrator: Firecracker API, process manager, vsock server (chmod 0o600, disconnect rejection), sandbox manager (orphan sweep), HTTP API (17 routes + bearer auth + two-tier rate limiting + 127.0.0.1 default), system prompt builder |
packages/cortex-tools |
@nsys-cortex/tools |
AI Agent Toolkit: 27 tools (with single-purpose process_*/neuron_*/memory_* factories), policy engine (recursive bash-c unwrap), NeuronProxy with shell-escaped paths, tool registry, memory store, session manager, MCP integration (config mcpServers → tools loaded at startup, lazy-loaded behind mcp_find_tools above the defer threshold) |
packages/cortex-runtime |
@nsys-cortex/runtime |
Agent runtime: 9 LLM providers with prompt caching + extended thinking, model failover chain (FailoverProvider + NSYS_CORTEX_LLM_FAILOVER), structured outputs (responseFormat json_schema, per-provider mapping), vision input (images on the user turn, persisted as attachments), agent loop (always-yield run_complete, retry/backoff with prefetched first event, AbortSignal propagation, parallel tools, tool_error event), context management (tokenizer strategy: heuristic / Anthropic / js-tiktoken), tool-pair-safe compaction, loop detection, atomic per-agent JSONL session persistence with full tool-call/tool-result fidelity + reload reconstruction + cumulative per-session usage (SessionInfo.usage) |
packages/cortex-cli |
@nsys-cortex/cli |
CLI for manual testing, operations, and interactive AI chat. Dev mode requires NSYS_CORTEX_ALLOW_INSECURE_DEV=1. chat defaults to coding preset; --admin opts into the full preset. Session resume (--continue/--session), TTY progress spinner, Ctrl-C run cancellation, /model /cost /save chat commands, config get/set/unset for editing config.json. |
Communication Flow
- Host creates a Neuron (spawns Firecracker VM or child process)
- Vsock server starts listening on a Unix domain socket
- Neuron agent boots, connects to the vsock server
- Host sends commands (
exec,file_write,file_read,file_list,stat,file_exists,mkdir,resolve_path,health,shutdown,pty_spawn,pty_write,pty_read,pty_resize,pty_close) as JSON messages over the socket - Neuron agent executes the command and sends back a JSON response
- Request-response correlation is done via unique message IDs with per-command timeout handling (default 30s, overridable per command via
timeoutMs)
Persistence
Nsys Cortex uses an overlay filesystem model:
- Base rootfs (read-only) — Alpine Linux (
node:24-alpine) with Node.js 24, Python, git, etc. - Overlay disk (read-write) — Per-neuron persistent layer
Any changes made inside the neuron (package installs, file edits, config changes) are written to the overlay. When the neuron is destroyed and recreated with the same ID, the overlay is reattached, and all changes reappear.
In dev mode, the overlay is a directory on the host filesystem (~/.nsys-cortex/overlays/<agent-id>/ by default, configurable via NSYS_CORTEX_OVERLAY_STORE_DIR).
In Firecracker mode, the overlay is a sparse ext4 disk image attached to the VM as a secondary drive.
Use --wipe with destroy to permanently delete the overlay.
Design Decisions
Why MicroVMs Over Docker
AI agents need to execute arbitrary code — installing packages, running scripts, modifying system files. Docker containers share the host kernel and rely on namespace isolation, which has a history of escape vulnerabilities. Firecracker MicroVMs provide hardware-level isolation via KVM, giving each agent its own kernel with a minimal attack surface.
| Aspect | Docker Container | Firecracker MicroVM |
|---|---|---|
| Isolation | Namespace (kernel shared) | Hardware (separate kernel via KVM) |
| Escape risk | Moderate (shared kernel surface) | Minimal (only virtio + minimal devices) |
| Boot time | ~200ms | ~125ms |
| Memory overhead | ~10MB | ~5MB (minimal guest kernel) |
| Persistence | Volume mounts | Overlay disk images |
| Networking | Bridge/NAT | TAP + iptables |
| Snapshot/restore | Not supported natively | Full VM snapshot in ~5ms |
The boot time advantage of Firecracker, combined with true hardware isolation and native snapshot support, makes it the right choice for an AI agent execution platform where untrusted code runs routinely.
Persistence Strategy
The persistence model has three levels:
-
Overlay disk (always-on) — Every change the agent makes is captured in a per-neuron overlay. When the neuron is destroyed and recreated with the same ID, the overlay is reattached. This is the primary persistence mechanism and requires no explicit action from the agent.
-
VM snapshots (~5ms restore) — Full VM state can be captured and restored, preserving in-memory state, open file handles, and running processes. Useful for checkpoint/restore patterns.
-
Remote backup (planned) — Overlay images can be exported for disaster recovery or migration between hosts.
This layered approach means the agent never loses work. Package installs, configuration changes, and file edits all survive neuron restarts automatically.
Naming Metaphor
The naming follows a neuroscience metaphor:
- Cortex — The outer layer of the brain responsible for higher-order processing. In Nsys, it is the host orchestrator that coordinates all execution environments.
- Neuron — An individual processing unit. Each sandboxed VM is a Neuron — isolated, specialized, and communicating with the Cortex through defined channels (vsock).
This metaphor extends naturally: neurons can be created, destroyed, snapshotted, and restored. The cortex manages their lifecycle and routes communication between them.
Dev Mode
Dev mode exists so that development and testing can happen on any platform (macOS, Windows, Linux without KVM) without requiring Firecracker. It substitutes:
- MicroVM → child process
- vsock → Unix domain socket (under
~/.nsys-cortex/sockets/, mode 0o700; socket files chmod'd 0o600 afterlisten()) - ext4 overlay disk → host filesystem directory
The ISandboxManager interface ensures all three modes expose the same API, so all code above the sandbox layer works identically regardless of mode. Each SandboxState carries workdir — the workspace path as seen inside the neuron (/home/agent/workspace for docker + firecracker, the overlay dir for dev) — which the tool proxy uses as cwd; overlayDiskPath is the host-side persistence location and is only a valid cwd in dev mode.
Docker Mode
NSYS_CORTEX_MODE=docker runs the agent in a hardened container built from the same image as the Firecracker rootfs (rootfs/Dockerfile.rootfs, built by npm run build:image). It reuses the dev-mode Unix-socket transport — the host's CortexVsockServer listens at ~/.nsys-cortex/sockets/docker/<id>/vsock.sock and that directory is bind-mounted into the container at /run/nsys-cortex/. The neuron's home (~/.nsys-cortex/overlays/<id>/home) is bind-mounted at /home/agent, so the workspace, memory and caches persist and stay owned by the operator's uid. The docker run argv is built by the pure sandbox/docker-config.ts (non-root uid, --cap-drop ALL, no-new-privileges, --read-only + tmpfs /tmp, memory/cpu/pids caps, --init, --rm, exactly two mounts, allow-listed env) and is pinned by unit + security-regression tests; the boundary notes live in Security Posture → Docker mode. Docker mode is also what the default mode auto resolves to whenever docker version answers and the image exists (resolveRuntimeMode() in sandbox/mode.ts, run once per process and written back to NSYS_CORTEX_MODE; without Docker it falls back to dev mode and the CLI prints why).
The channel is chosen per daemon (ADR-009). NSYS_CORTEX_DOCKER_TRANSPORT = auto (default) | unix | tcp. unix — the daemon shares the host's kernel (Linux Docker Engine): the host listens on a Unix socket in the per-neuron channel dir (~/.nsys-cortex/sockets/docker/<id>/, 0o700, bind-mounted at /run/nsys-cortex), as before. tcp — the daemon runs in a VM (Docker Desktop) and no socket can be shared: the agent listens on port 9999 inside the container, the container publishes it on the host's loopback interface only (-p 127.0.0.1:0:9999/tcp), the host writes a fresh 32-byte token owner-only into that same channel dir, reads the host port back with docker port and dials; the first frame is a hello carrying the token, a wrong token closes the connection, one connection is accepted. Docker's port proxy accepts a connection as soon as the port is published and closes it when nothing listens behind it yet, so the host dials again until the boot deadline (commands sent in the gap are queued and re-sent) and a wrong token surfaces only at bootTimeoutMs. auto picks tcp when docker info reports Docker Desktop. The token never enters the container's environment and goes with the channel dir on destroy() and on the agent's death; the none network has no port to publish, so none + tcp is refused, never downgraded.
Discovery mirrors dev mode with two differences: a host-only .neuron.docker.json (kept outside the bind mount so the agent cannot rewrite what the host reaps) records the neuron, liveness is a single docker ps --filter label=nsys-cortex.managed=true per list()/get() rather than PID probing, and because containers are addressable by name, destroy() and the stale-pending orphan sweep work from any host process (docker rm -f). DockerCortexSandboxManager.cleanupOrphans() removes labeled containers that no state file knows about; like its dev-mode counterpart it is never called automatically. The factory self-check fails closed unless the daemon answers and the image exists.
Agent death is observed the way the other managers observe it: the vsock neuron-disconnected event (or the docker client's exit, whichever lands first) flips the in-memory neuron to stopped and releases its state file and socket dir, so other processes see stopped too; execute() then throws NotRunningError (HTTP 409 NOT_RUNNING), chat restarts the neuron before its next turn, and the next create() replaces the corpse. The boot itself is raced against the docker client's close, so a spawn failure, a missing image or a refused mount fails create() immediately with docker's own error rather than after bootTimeoutMs. A docker client that exits while the agent is still connected (the client was killed, not the container) only loses the log pipe — the daemon keeps the container running.
Dev mode is NOT a security boundary. The agent child runs as the host user. The dev sandbox does pass a scrubbed env via buildAgentEnv() (no *_API_KEY, AWS_*, SSH_*, etc.) so a basic env-dump exfil is blocked, but a bash tool call can still read any file your user can read. The CLI refuses to start in dev mode without NSYS_CORTEX_ALLOW_INSECURE_DEV=1 to make this acknowledgement explicit. --mode=firecracker is the intended isolation boundary; its hardening (jailer, vsock bridge, outbound-only NAT, runtime self-check) is implemented but not yet validated on a KVM host (see Security Posture → Firecracker isolation — implemented, pending KVM validation) — until the validation checklist passes, run only trusted workloads in it.
In dev mode, create() writes a .neuron.json state file to the overlay directory containing the agent ID, PID, socket path, vsock CID, creation timestamp, and a pending: true flag. The state file is written before waitForNeuron/health-check; the flag is cleared on success. list() and get() use three-tier neuron discovery to merge results from multiple sources:
- In-memory neurons — neurons created by the current process (always accurate)
- State file discovery — scans overlay directories for
.neuron.jsonfiles, verifies PID liveness viaprocess.kill(pid, 0). Stale state files (dead PID) are automatically cleaned up along with their socket files. Stale pending state files (older than 60s) trigger an orphan sweep: the agent process is SIGKILL'd and the state file removed. This eliminates the "invisible zombie node process" failure mode where a host crash mid-boot left an agent running with no path back to a controlling process. - Overlay-based fallback — for overlay directories without a
.neuron.json(e.g., state file lost or neuron created before the state file feature), checks if the neuron is still running by: (a) looking for the vsock socket file on disk, or (b) queryinglsof -U -a -c nodeto detect open sockets even when the filesystem entry has been deleted. If neither check succeeds, the neuron is listed asstopped.
This allows nsys-cortex list in one terminal to see neurons created by nsys-cortex chat in another, including neurons whose state files are missing. Stopped neurons (overlay exists but neuron is not running) are also shown, so users can see which agent IDs have persistent data. Cross-process execute() is not supported — the vsock Unix socket server is owned by the creating process. create() for a neuron another live process runs is refused with a typed OwnedElsewhereError (the API's 409 OWNED_ELSEWHERE): docker mode verifies that the state file's docker run client pid is alive and carries --name <container>, dev mode that the agent pid is alive and bound to the overlay dir (isAgentProcessFor); only an orphan — owner gone, container or agent still up — is taken over.
The DevCortexSandboxManager.cleanupOrphans() method uses pgrep -f cortex-agent/dist/index.js to find leaked agent processes whose state files were manually deleted. It's best-effort and never called automatically — invoke it explicitly from operator scripts when needed (the cross-manager visibility required to be safe in unit tests doesn't exist).
Project Structure
nsys-cortex/
├── packages/
│ ├── cortex-shared/ Shared types, schemas, utilities
│ │ └── src/
│ │ ├── types.ts Type definitions (SandboxConfig, AgentCommand, etc.)
│ │ ├── schemas.ts Zod validation schemas
│ │ ├── protocol.ts Protocol constants (ports, delimiters, version)
│ │ ├── logger.ts Structured JSON logger (per-target log levels, optional console)
│ │ ├── file-logger.ts File-based logging (daily rotation, size limits, cleanup)
│ │ ├── config.ts Config file loader (schema, env mapping, getLogsDir)
│ │ ├── config-edit.ts Config read/edit helpers (backs `nsys-cortex config`)
│ │ ├── id.ts ID generation (nanoid) + deterministic CID
│ │ └── index.ts Barrel exports
│ ├── cortex-agent/ Neuron-side agent (runs inside VM/process)
│ │ └── src/
│ │ ├── vsock-client.ts NeuronVsockClient — connects to host
│ │ ├── executor.ts NeuronExecutor — exec, file I/O, path validation
│ │ ├── health.ts NeuronHealthMonitor — uptime, memory, command count
│ │ ├── pty-manager.ts PtyManager — PTY session lifecycle, buffered I/O
│ │ └── index.ts Boot sequence, command routing, signal handlers
│ ├── cortex-host/ Host-side orchestrator
│ │ └── src/
│ │ ├── firecracker/
│ │ │ ├── api-client.ts REST over Unix socket (11 methods)
│ │ │ ├── process-manager.ts Launch/kill Firecracker via the jailer; chroot staging
│ │ │ ├── jailer-config.ts Pure jailer argv / chroot-path / cgroup-arg helpers (unit-tested)
│ │ │ └── chroot.ts Stage kernel/rootfs/overlay into the jail; cleanup
│ │ ├── network/
│ │ │ └── nat.ts NeuronNetwork — per-neuron tap + outbound-only NAT/egress firewall
│ │ ├── vsock/
│ │ │ └── server.ts CortexVsockServer — framing, correlation, receive-buffer cap, uid-owned socket
│ │ ├── sandbox/
│ │ │ ├── types.ts ISandboxManager interface
│ │ │ ├── manager.ts CortexSandboxManager (Firecracker mode: jailer + NAT + ceiling)
│ │ │ ├── allocator.ts NeuronResourceAllocator — per-neuron uid/tap//30/MAC (+ CapacityError)
│ │ │ ├── dev-sandbox.ts DevCortexSandboxManager (dev mode, cross-process discovery)
│ │ │ ├── docker-config.ts PURE docker-mode helpers: `docker run` argv (the boundary spec), env allow-list, naming
│ │ │ ├── docker-sandbox.ts DockerCortexSandboxManager (hardened container, UDS bind-mounted, docker-ps discovery)
│ │ │ ├── factory.ts Factory + runtime self-checks (docker: daemon+image; firecracker: KVM/jailer/root/cgroup-v2/iptables)
│ │ │ └── cleanup.ts Stale socket/orphan process + chroot/cgroup sweep
│ │ ├── api/
│ │ │ ├── server.ts Express server, 17 routes
│ │ │ └── validation.ts Zod request schemas + middleware
│ │ ├── prompt/
│ │ │ └── system-prompt.ts buildCortexSystemPrompt (AGENTS.md, SOUL.md, TOOLS.md)
│ │ ├── utils/
│ │ │ └── disk.ts Overlay disk create/check/delete
│ │ └── __tests__/
│ │ ├── unit/ API client + vsock server tests
│ │ └── integration/ Dev sandbox + Firecracker tests
│ ├── cortex-tools/ AI Agent Toolkit
│ │ └── src/
│ │ ├── types.ts CortexTool interface, ToolContent, ToolGroup
│ │ ├── proxy.ts NeuronProxy — abstraction over ISandboxManager (+ exported shq() shell-escape, resolvePath jail check)
│ │ ├── validate-input.ts validateToolInput — coerce+validate model args vs the TypeBox schema before execute()
│ │ ├── registry.ts CortexToolRegistry — register, filter, presets
│ │ ├── index.ts Public API, preset factories
│ │ ├── tools/ 19 tool implementations
│ │ │ ├── read.ts, write.ts, edit.ts, bash.ts Core tools
│ │ │ ├── grep.ts, find.ts, ls.ts Search tools
│ │ │ ├── apply-patch.ts, process.ts, elevated.ts Runtime tools
│ │ │ ├── snapshot.ts, neuron.ts Neuron tools
│ │ │ ├── memory.ts Memory tools (search/get/update)
│ │ │ └── sessions.ts Session tools (list/send/spawn/history)
│ │ ├── policies/ Policy engine
│ │ │ ├── types.ts, engine.ts PolicyEngine composite + presets
│ │ │ ├── path-policy.ts, command-policy.ts Path globs, command filtering
│ │ │ ├── resource-policy.ts, tool-access-policy.ts Output limits, group access
│ │ ├── memory/ Persistent memory store
│ │ │ ├── types.ts, store.ts, search.ts
│ │ ├── sessions/ Session management
│ │ │ ├── types.ts, manager.ts, router.ts
│ │ ├── mcp/ External MCP server integration
│ │ │ ├── types.ts, adapter.ts, env.ts, defer.ts
│ │ └── __tests__/
│ │ ├── tools/ Unit tests (core-tools, advanced-tools)
│ │ ├── policies.test.ts Policy engine unit tests
│ │ ├── registry.test.ts Registry unit tests
│ │ └── integration/ E2E tests with real dev sandbox
│ ├── cortex-runtime/ Agent runtime (LLM integration)
│ │ └── src/
│ │ ├── types.ts Runtime types (messages, events, config)
│ │ ├── agent-loop.ts CortexAgentLoop — core execution loop
│ │ ├── session-storage.ts FileSessionStorage — JSONL persistence
│ │ ├── providers/
│ │ │ ├── anthropic.ts Anthropic Claude provider
│ │ │ ├── openai.ts OpenAI-compatible provider (also used by Grok, Mistral, Ollama, vLLM, LM Studio, SGLang)
│ │ │ ├── gemini.ts Google Gemini provider
│ │ │ └── factory.ts Provider factory (9 providers)
│ │ ├── context/
│ │ │ ├── manager.ts ContextManager — budget tracking
│ │ │ ├── tokenizer.ts Tokenizer interface + SimpleTokenizer fallback
│ │ │ ├── tokenizers/ AnthropicTokenizer (SDK countTokens) + OpenAITokenizer (js-tiktoken), injected via provider.getTokenizer()
│ │ │ └── compaction.ts History compaction via summarization
│ │ ├── safety/
│ │ │ └── loop-detector.ts ToolLoopDetector — 4 detection patterns
│ │ └── __tests__/ Unit tests (agent loop, context, loops, providers, storage)
│ └── cortex-cli/ CLI for manual testing
│ ├── bin/nsys-cortex Launcher: runs the compiled bundle on plain node (falls back to tsx pre-build)
│ └── src/
│ ├── index.ts Commander program, 10 commands (bundled to dist/index.js with esbuild)
│ ├── banner.ts ASCII art banner
│ ├── markdown.ts Terminal markdown rendering (marked + marked-terminal)
│ ├── spinner.ts Dependency-free TTY spinner (thinking…/running…)
│ ├── suggestions/ Ghost-text next-step hints (generator + prompt)
│ └── commands/ Individual command handlers
├── scripts/
│ ├── setup-firecracker.sh Download Firecracker v1.7.0 + kernel
│ ├── build-neuron-image.sh Build nsys-cortex-neuron:latest (docker mode runs it; node-pty compiled inside)
│ └── build-rootfs.sh Export that image into the ext4 rootfs Firecracker boots
├── rootfs/
│ ├── Dockerfile.rootfs node:24-alpine neuron image (docker mode + Firecracker rootfs)
│ └── init.sh VM PID 1 init (overlay setup, agent start)
├── resources/ vmlinux + rootfs.ext4 (gitignored)
├── data/ Overlays + snapshots (gitignored)
├── vitest.config.ts Test config (30s timeout)
├── tsconfig.base.json Shared TypeScript config
├── Makefile Build/dev/test shortcuts
├── .env.example Configuration reference
└── CHANGELOG.md Release history