{"slug": "nine-single-binary-wasm-tools-and-persistent-ai-harness-runtime", "title": "Nine – single binary, WASM tools and persistent AI harness/runtime", "summary": "Nine, a self-hosted AI agent runtime from developer djordlucas, ships as a single Go binary that runs local models through Ollama or the hosted Mistral API with persistent SQLite state, a TUI and an OpenAPI-specified REST API. The binary is published for Docker on linux/amd64 and linux/arm64, implements client, server and plugin roles at once, and draws tools from plugins, MCP servers and sandboxed JS/Wasm executed in-process via wazero capability grants. Nine is developed against small models as a baseline, and its GitHub Actions are disabled, so the `make ci` gate must be run locally before merging.", "body_md": "\n\n```\n ███╗   ██╗██╗███╗   ██╗███████╗\n ████╗  ██║██║████╗  ██║██╔════╝\n ██╔██╗ ██║██║██╔██╗ ██║█████╗\n ██║╚██╗██║██║██║╚██╗██║██╔══╝\n ██║ ╚████║██║██║ ╚████║███████╗\n ╚═╝  ╚═══╝╚═╝╚═╝  ╚═══╝╚══════╝\n```\n\nNine is a **self-hosted AI agent runtime in a single Go binary**: local models through Ollama\nor the hosted Mistral API, persistent SQLite state, a TUI and an OpenAPI-specified REST API,\nand tools from plugins, MCP servers and sandboxed JS/Wasm.\nUse Nine to research subjects, work on codebases, automate processes, experiment.\nNine is developed against small models as a baseline.\n\nThe binary ships for Docker, Linux and macOS, and implements client, server and plugin roles at once. Each session runs a dedicated agent loop that plans work, calls tools, persists data and orchestrates sub-agent loops — interactively through the TUI, or unattended through scheduled and periodic goals. Everything — conversations, goals, memories, session events — lives in one SQLite file.\n\nNo clone needed — the image ships a working config. Published for `linux/amd64` and\n`linux/arm64`. The package is private, so authenticate first with a GitHub token carrying\n`read:packages`.\n\n```\n# 1. Registry access and a model on the host\necho \"$CR_PAT\" | docker login ghcr.io -u <your-github-username> --password-stdin\nollama pull qwen3.5:4b\n\n# 2. Nine\ndocker run -d --name nine \\\n  -p 127.0.0.1:8080:8080 \\\n  --add-host host.docker.internal:host-gateway \\\n  -v nine-data:/data \\\n  ghcr.io/djordlucas/nine:latest\n\n# 3. A session\ndocker exec -it -u nine nine nine\n```\n\nPoint Nine at a different LLM without a config file, and verify what you pulled before running it:\n\n```\ndocker run -d --name nine -v nine-data:/data \\\n  -e NINE_LLM_ENDPOINT=http://192.168.1.10:11434 \\\n  -e NINE_LLM_MODEL=qwen3.5:9b \\\n  ghcr.io/djordlucas/nine:latest\n\ncosign verify ghcr.io/djordlucas/nine:latest \\\n  --certificate-identity-regexp '^https://github.com/djordlucas/nine/.github/workflows/release-image.yml@' \\\n  --certificate-oidc-issuer https://token.actions.githubusercontent.com\n```\n\nFrom a clone, for hacking on Nine or building the image yourself. `make all` produces\nexactly one file, `dist/nine` — the CLI, the TUI, the daemon and the four built-in\nplugins. Deploying Nine is copying that file.\n\n```\ngit clone https://github.com/djordlucas/nine\ncd nine\nmake all       # native build -> dist/nine\nmake up        # or: build and run the runtime image\nmake session   # interactive TUI session\n```\n\nNine reads its config from `$NINE_CONFIG`, `./nine.toml`, `/nine.toml`, then\n`~/.nine/nine.toml`, and creates its database on first run at `~/.nine/nine.db`. The repo's\n`nine.toml` is a development config and works as-is against a local Ollama; the published\nimage's baked config is narrower. Configuration belongs to the operator — Nine cannot rewrite\n`nine.toml` at runtime.\n\n**`make ci` is the gate.** GitHub Actions is disabled for this repository, so nothing runs on\na push; run it locally before merging. `make scan` adds the Trivy passes. Both need only\nDocker.\n\nPrerequisites, the full Makefile target list, the container's environment overrides and the\nconfiguration reference: [installation](https://github.com/djordlucas/nine/blob/main/docs/installation.md),\n[Docker image](https://github.com/djordlucas/nine/blob/main/docs/docker-image.md), [configuration](https://github.com/djordlucas/nine/blob/main/docs/configuration.md).\n\n| Concept | Description | \n|---|---|\n| **UI** | CLI or TUI, both thin clients over the daemon's Unix socket | \n| **Daemon** | Long-running background process; manages agents, plugins and state | \n| **Agent** | An LLM agent running the ReAct loop | \n| **Plugin** | A tool container — a standalone binary, or an MCP server | \n| **Sandboxed tool** | User-supplied JS or wasm run in-process in a wasm sandbox (wazero), through capability grants | \n| **Generated tool** | A sandboxed tool Nine wrote itself, stored as a row; its code is the agent's, its capabilities the operator's | \n| **Capability** | A conferred reach — `fs` ,`env` ,`net.http` — declared by a tool's manifest, granted by the operator in`nine.toml` or by approving a request | \n| **Skill** | Markdown how-to note, semantically retrieved into context | \n| **Goal** | An open-ended intention with no end condition, pursued in the background | \n\nEvery other term — workflow, session plan, standing agent, supervisor, checkpoint, journal —\nis defined in the [glossary](https://github.com/djordlucas/nine/blob/main/docs/glossary.md).\n\n**Modular.** Built-in tools, custom plugins, MCP servers, and JS/Wasm tools all reach the agent\nthrough one dispatcher. Roles gate which of them a given worker may call.\n\n**Persistent.** Conversations, goals, workflows, memory, files, skills and generated tools live\nin a database file, and every turn is checkpointed — kill the daemon mid-task and it resumes\nwith the same history and plan.\n\n**Autonomous.** Given a goal, Nine spawns a background session that wakes on an interval to push\nit forward. Standing agents declared in `nine.toml` skip the human entirely: cron-scheduled,\nnarrowly tool-scoped, surfacing findings to `nine notifications`. Background work always runs at\nlower priority than your active conversation.\n\n**Auditable.** An append-only journal records every step the agent has taken — turn boundaries,\nthe exact LLM request and response, tool I/O, context usage, sub-agent lifecycle. `nine trace`\nand `nine replay` read it back; `nine context` shows the session's current context.\n\n**Evolving.** Nine writes its own skills, and its own sandboxed tools, to close capability gaps\n— bounded by a capability ceiling that defaults to the workspace. The agent writes the code, the\noperator writes the grants.\n\n**Local.** Built to run against a local model through Ollama, with a SQLite database; `mistral`\nis the hosted alternative when you want one ([configuration](https://github.com/djordlucas/nine/blob/main/docs/configuration.md#mistral)).\nTested against `qwen3.5:4b`, `qwen3.5:9b`, `gemma4:e4b` and `gemma4:e2b` on a 16 GB M4\n([model compatibility](https://github.com/djordlucas/nine/blob/main/docs/model-compatibility.md)).\n\nNine is a daemon/client pair. The CLI is thin — it opens a Unix socket, sends a message, prints the reply. Everything long-lived is in the daemon.\n\n```\n  nine <message> / TUI  ──JSON over Unix socket──┐   (thin client: send, print)\n                                                 │\n┌────────────────────────────────────────────────▼──────────────────────────┐\n│ Daemon                                                                    │\n│                                                                           │\n│   Conversation Manager ──spawns──┐        Supervisor Agent ──monitors──┐  │\n│                                  ▼                                     ▼  │\n│                       ┌──────────────────────────────────────────────────┐│\n│                       │ Agent Loops   ReAct: reason → act → observe      ││\n│                       │ builds context from roles, skills, tools         ││\n│                       └───────┬──────────────────────────┬───────────────┘│\n│                               │                          │                │\n│            ┌──────────────────▼─────────┐   ┌────────────▼──────────────┐ │\n│            │ LLM Queue → provider       │   │ Tool Dispatcher           │ │\n│            │ supervisor > active        │   │  ├ core (in-process)      │ │\n│            │           > background     │   │  ├ plugins (subprocess)   │ │\n│            └────────────────────────────┘   │  └ wasm host (capability) │ │\n│                                             └────────────┬──────────────┘ │\n│   ┌──────────────────────────────────────────────────────▼──────────────┐ │\n│   │ memory.Store → SQLite, one file: all durable state + event journal  │ │\n│   └─────────────────────────────────────────────────────────────────────┘ │\n└───────────────────────────────────────────────────────────────────────────┘\n```\n\n**The context builder** treats context as a budget rather than a buffer. Sources compete by\npriority — system prompt, tool definitions, self-model, enrichment, message history, scratchpad —\nand low-priority ones are trimmed when the budget is tight. Tool definitions are capped at top-N\nand ranked by cosine similarity against the query, so the model sees the tools that matter for\n*this* turn.\n\n**The LLM queue** serializes requests across every concurrent agent, prioritizing the supervisor\nabove active conversations above background work, so a goal grinding away in the background never\nmakes you wait.\n\n**Memory** is one SQLite file reached through a single store, covering conversations, goals,\nworkflows, KV memory, full-text-searchable files, vectors, skills, generated tools, session plans,\nhuman-in-the-loop state, and the event journal. Operational tables are daemon-private, so an agent\ncannot reach in and rewrite its own goal state.\n\n**The event journal** is written off the turn's critical path by an async batched sink, and is\nsubscribable with durable per-subscriber cursors. The first subscriber links topically-similar\nsessions so a later turn can pull relevant prior context in.\n\nTopology, concurrency, the turn lifecycle, boot sequence and the invariants that hold it together:\n[docs/architecture.md](https://github.com/djordlucas/nine/blob/main/docs/architecture.md).\n\n**The tool dispatcher** is the single place a tool name resolves to an implementation, with three\nbackends behind one namespace: core tools calling the store in-process, plugin subprocesses, and\nthe wasm sandbox host. A name resolves to exactly one — an unexpected collision is a load failure,\nnot a silent override.\n\n**Plugins** are tool containers, and may run asynchronous jobs. Nine ships four — `shell`,\n`files`, `http`, `time` — served out of the `nine` binary itself: the daemon starts each by\nre-executing itself as `nine plugin serve <name>`, so they keep process and crash isolation\nwithout their own artifact. A crashing plugin cannot take the daemon down with it. An **MCP\nserver** declared as `[[mcp.server]]` becomes a plugin too, with its tools prefixed by the server\nname; that is how Nine drives a browser ([docs/browser.md](https://github.com/djordlucas/nine/blob/main/docs/browser.md)).\n\nWriting one means answering two methods — `plugin.describe` and `plugin.call` — over HTTP on a\nUnix socket, then dropping the binary beside a manifest in `[plugins].user_dir`\n(`plugins.d/weather` + `plugins.d/weather.toml`). The description string is what gets embedded and\nranked for tool selection, so it decides whether your tool is offered to the model at all.\nSee [docs/plugins.md](https://github.com/djordlucas/nine/blob/main/docs/plugins.md) and\n[docs/plugins-http-transport.md](https://github.com/djordlucas/nine/blob/main/docs/plugins-http-transport.md).\n\nMemory, durable file storage, semantic search and skills are **not** plugins — they are core\ntools, wired into the agent loop and calling the store in-process. They are still ordinary tools\nfrom the model's side.\n\nSandboxed tools are files you drop in a directory, run in a wasm sandbox in-process with exactly\nthe capabilities the operator granted — by default, **none**. No subprocess, no compile step, no\nimage rebuild. The host runs by default; your own tools live in `[tools] user_dir`\n(`./tools.d`), and a `.js` or `.wasm` file with no manifest beside it is never loaded.\n\n```\ntools.d/\n  csvstats.toml        # the manifest — the gate, and where the model's view comes from\n  csvstats.schema.json # the argument schema\n  csvstats.js          # the code: default-export a function\n```\n\nThe `js` kind runs on a trimmed QuickJS-NG interpreter compiled to wasm: **ES2023 and nothing\nelse** — no Node standard library, no `require`, no `setTimeout`, no `URL`, and `fetch` only where\n`net.http` was granted. Bundle dependencies at development time. For another language or full\nspeed, ship a `.wasm` module from Rust, TinyGo, Zig or C exporting the two-function ABI\n(`nine_alloc`, `nine_run`).\n\n**The manifest declares a need; only `nine.toml` grants it.** The two must match exactly —\ndeclaring something ungranted fails to load, and being granted something undeclared fails too.\n\n```\n# csvstats.toml — the tool declares a need\n[capabilities]\nfs = [\"read\"]\n\n# nine.toml — the operator grants it, by name\n[tool.csv_stats.capabilities.fs]\nread = [{ host = \"/srv/data\", guest = \"/data\" }]\n```\n\n| Capability | You get | Default | \n|---|---|---|\n| `clock` ,`random` ,`log` | `Date.now()` ,`Math.random()` ,`console.*` | granted | \n| `fs.read` /`fs.write` | mounted directories, addressed by their *guest* path | declare + grant | \n| `env` | named keys only ( `NINE_*` and`*_API_KEY` can never be granted) | declare + grant | \n| `net.http` | a `fetch` subset | declare + grant | \n\nEverything with reach starts at nothing. A capability is either a wazero pre-open or a host\nfunction the daemon exports, so anything else is not \"denied\" but structurally absent: a sandboxed\ntool cannot spawn a process, open a socket, load a native library, or call another tool. `net.http`\nis the exception — wazero has no network, so it is a host function and its security is Nine's\nproblem: the hostname must match `allow_hosts` **and** the dialed address must be publicly\nroutable, checked immediately before connect, on every redirect hop. Loopback, link-local\n(including `169.254.169.254`) and RFC 1918 are refused regardless of the allowlist. Bounds are\nalways on: one instance per call, a 5s wall clock, 16 MiB.\n\nNine writes its own tools at runtime, as rows in the store rather than files on disk, running in\nthe identical sandbox under the identical rules. The tier is **on by default** with\n`[workspace].root` as its ceiling — the same directory the shipped file tools reach and `shell`\nruns in — and is gated by `[tools] enabled` above it.\n\n`[tools.agent.capabilities]` is a **ceiling**: the most any generated tool may be granted, never an\nautomatic grant. A tool gets a capability only by declaring it, one that declares nothing runs with\nnothing, and declaring past the ceiling is a refusal the model can act on. Declarations are\nre-resolved on every load, so narrowing the ceiling disables a tool that no longer fits rather than\nleaving it running with reach you withdrew. `tool_write` writes JavaScript and a capability\n*declaration*; it has no path to write a grant:\n\n|  | Code | Capabilities | \n|---|---|---|\n| Native plugin | operator (build time) | operator ( `nine.toml` ) | \n| Developer sandboxed tool | developer (file on disk) | operator ( `nine.toml` ) | \n| Generated sandboxed tool | **Nine** (runtime) | operator ( `nine.toml` ) | \n\nGenerated tools may always import a small vendored standard library (`nine:csv`, `nine:date`,\n`nine:diff`). External npm packages are a separate switch, off by default: imports are resolved\nonce, at write time, in the daemon, verified against published checksums, with no install scripts,\nand inlined — so by call time the tool has no imports left. A write can be gated on a human;\n`require_approval` defaults to `on_capability`. A new tool is visible **next turn**.\n\n``` js\n$ nine tools\n  ok    csv_stats          js     fs.read /srv/data=>/data\n  ok    due_date           gen    none\n  SKIP  scraper            js     capability fs.read is declared by the tool but not granted; add it\n                                  under [tool.<name>.capabilities] or remove the declaration\n```\n\nA tool that fails to load is always reported with its reason, and `gen` marks a tool as Nine's own.\n`nine tools show <name>` prints one in full, `nine tools deps` answers what third-party code is in\nthis daemon, `nine tools reload` re-scans live, and `nine tool validate` checks a manifest with no\ndaemon running. Authoring guide:\n[docs/writing-sandboxed-tools.md](https://github.com/djordlucas/nine/blob/main/docs/writing-sandboxed-tools.md). Design rationale and the full\ncapability model: [docs/sandboxed-tools.md](https://github.com/djordlucas/nine/blob/main/docs/sandboxed-tools.md), with the normative contract\nin `spec/contracts/toolvm.md` (`nine spec toolvm`).\n\nSkills are how Nine improves what it *knows*; generated tools are how it improves what it can\n*do*. A skill is a markdown how-to note with YAML frontmatter, stored in the database. Its\ndescription is embedded; when a skill is semantically relevant, its *name* surfaces into the\nagent's self-model and the agent reads the body on demand — on-demand documentation rather than a\npermanent context tax.\n\nThe boundary is deliberate: Nine writes skills and sandboxed-tool code, both store state, listable\nand deletable like a goal. It does not generate plugins, write itself a capability grant, rewrite\nits config, or rebuild its source at runtime. It can *ask*: `capability_request` records a request\nan operator approves or denies, and an approval takes effect without a restart\n([docs/self-modification.md](https://github.com/djordlucas/nine/blob/main/docs/self-modification.md)).\n\nDocumentation and specs live in this repo and inside the binary, rendered as markdown when\ninvoked — `nine docs [topic]` and `nine spec [topic]`, each listing its topics when given none.\n\n[docs/README.md](https://github.com/djordlucas/nine/blob/main/docs/README.md) is the full guide, ordered for a first-time reader:\n[CLI usage](https://github.com/djordlucas/nine/blob/main/docs/usage.md), [agent loop](https://github.com/djordlucas/nine/blob/main/docs/agent-loop.md) and\n[context builder](https://github.com/djordlucas/nine/blob/main/docs/context-builder.md), [the event journal](https://github.com/djordlucas/nine/blob/main/docs/event-journal.md),\n[session plans & routines](https://github.com/djordlucas/nine/blob/main/docs/session-plans.md), [roles](https://github.com/djordlucas/nine/blob/main/docs/roles.md),\n[predefined agents](https://github.com/djordlucas/nine/blob/main/docs/predefined-agents.md), [scheduling](https://github.com/djordlucas/nine/blob/main/docs/scheduling.md),\n[human-in-the-loop](https://github.com/djordlucas/nine/blob/main/docs/hitl.md), and the [glossary](https://github.com/djordlucas/nine/blob/main/docs/glossary.md).\n\n**Experimental, stabilizing.** Interfaces change without notice, and there is no support promise\nor stability guarantee.\n\nEvery feature lands with tests: unit tests, hermetic harness tests for the daemon and its wire\nprotocol, integration tests against a real container and a real model, and an eval suite that\nreplays recorded sessions deterministically and runs a live-model matrix\n([evals](https://github.com/djordlucas/nine/blob/main/docs/evals.md), [model compatibility](https://github.com/djordlucas/nine/blob/main/docs/model-compatibility.md)). What that does not\nbuy is user mileage — the failure modes that only long uninterrupted runs, unusual hardware or an\nunfamiliar model turn up are still ahead of it. Please open an issue when you hit one.\n\nWhat is still missing, and what is partially there: [docs/roadmap.md](https://github.com/djordlucas/nine/blob/main/docs/roadmap.md).\n\n| Limit | Detail | \n|---|---|\n| Not hardened | Only sandboxed tools run behind a real boundary. The `shell` plugin and native plugins run as the daemon's process user with its full filesystem and network reach. In the published image that user is an unprivileged uid 1000, so the reach stops at the container. | \n| API auth is off until configured | The API supports a bearer token ( `[api] auth_token` ,`--auth-token` ,`NINE_API_AUTH_TOKEN` ) and TLS. Neither is on by default, and the published image binds`0.0.0.0` — set a token, or publish the port to loopback. Nine warns at startup when it binds a non-loopback address with no token. | \n| Single host | The daemon listens on a Unix socket, so every client runs on the same machine. | \n| One model at a time | No routing across models within a deployment. | \n| Ollama and Mistral only | Other providers are refused at startup rather than falling back. | \n| Small-model baseline | Behavior on large hosted models is unmeasured — [model compatibility](https://github.com/djordlucas/nine/blob/main/docs/model-compatibility.md) . | \n| Interfaces change without notice | No stability guarantee and no support promise while the project is experimental. | \n| Low user mileage | Failure modes that only long runs, unusual hardware or an unfamiliar model turn up have not been hit yet. | \n| Config is operator-only | Nine cannot rewrite `nine.toml` at runtime; changing a setting means editing the file and restarting. An approved capability grant is recorded in the store and installed on the running daemon — nothing writes the file. | \n| No external pull requests | Deliberate — see [Contributing](#contributing) . | \n\n**Sandboxed tools are sandboxed; nothing else is.** The wasm host is a real boundary —\ndefault-deny capabilities, one instance per call, an SSRF-checked HTTP path — and it applies to\nsandboxed tools only. The `shell` plugin runs commands as the daemon's process user, plugins are\nordinary subprocesses with the daemon's own reach, and the agent has real filesystem and network\naccess through them. Run Nine in Docker or under a restricted user if you are pointing it at\nanything you don't trust.\n\nIn the published image the daemon runs as an unprivileged uid 1000, so that reach stops at the\ncontainer: the agent cannot write outside `/data` or install packages. The image is signed with\ncosign and carries an SBOM and build provenance ([docs/docker-image.md](https://github.com/djordlucas/nine/blob/main/docs/docker-image.md)).\nThe API on port 8080 has no authentication until you configure one — bind it to localhost, as the\nquick start does, or put it behind a reverse proxy.\n\n`[tools.agent]` is on by default, so the agent writes code that then runs — bounded by a ceiling\nthat defaults to `[workspace].root`. That is the same directory the shipped file tools reach and\n`shell` runs in, so it is not new reach for the agent; what it adds is reach for a *generated\ntool's dependencies*. Narrow the ceiling with an explicit fs grant if your workspace holds secrets,\nor set `enabled = false`. Enabling external npm dependencies for that tier is the riskiest switch\nin the system and is off by default, as are `allow_long_running` and `allow_standing`.\n\nReporting a vulnerability: [.github/SECURITY.md](https://github.com/djordlucas/nine/blob/main/.github/SECURITY.md).\n\n**Issues yes, pull requests no.** Bug reports, questions and ideas are genuinely welcome — please\nopen an issue. Pull requests won't be merged; this is a personal project developed solo, and\nkeeping it single-author is a deliberate choice. See [CONTRIBUTING.md](https://github.com/djordlucas/nine/blob/main/CONTRIBUTING.md) and the\n[code of conduct](https://github.com/djordlucas/nine/blob/main/CODE_OF_CONDUCT.md).\n\nThis project was made with the author's ideas, experience and orchestration, and built with Claude.\n\nGPL-3.0-or-later. See [LICENSE](https://github.com/djordlucas/nine/blob/main/LICENSE).\n\nCopyright (C) 2026 The Nine Authors\n\nThis program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.", "url": "https://wpnews.pro/news/nine-single-binary-wasm-tools-and-persistent-ai-harness-runtime", "canonical_source": "https://github.com/djordlucas/nine", "published_at": "2026-09-30 22:26:36+00:00", "updated_at": "2026-09-30 22:48:51.855412+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "agent-protocols", "ai-infrastructure"], "entities": ["Nine", "djordlucas", "Ollama", "Mistral API", "SQLite", "wazero", "GitHub Actions", "Docker"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/nine-single-binary-wasm-tools-and-persistent-ai-harness-runtime", "markdown": "https://wpnews.pro/news/nine-single-binary-wasm-tools-and-persistent-ai-harness-runtime.md", "text": "https://wpnews.pro/news/nine-single-binary-wasm-tools-and-persistent-ai-harness-runtime.txt", "jsonld": "https://wpnews.pro/news/nine-single-binary-wasm-tools-and-persistent-ai-harness-runtime.jsonld"}}