A llm harness framework, built to be extendable
  • TypeScript 65.2%
  • JavaScript 29%
  • CSS 4.2%
  • HTML 1%
  • Mustache 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-21 15:33:36 +02:00
.zaneku added configurable number of steps per turn 2026-09-19 06:16:44 +02:00
docs Settings dialog continued, still WIP, but better 2026-09-21 15:33:36 +02:00
eslint-rules ui rewrite. still ugly, but architecture is clean 2026-09-18 03:45:28 +02:00
plugins rebranding 2026-09-18 05:18:29 +02:00
src translation, WIP 2026-09-21 00:43:30 +02:00
test Settings dialog continued, still WIP, but better 2026-09-21 15:33:36 +02:00
ui Settings dialog continued, still WIP, but better 2026-09-21 15:33:36 +02:00
.gitignore rebranding 2026-09-18 05:18:29 +02:00
.prettierignore initial version 2026-09-16 23:01:34 +02:00
esbuild.config.mjs translation, WIP 2026-09-21 00:43:30 +02:00
eslint.config.js rebranding 2026-09-18 05:18:29 +02:00
package.json translation, WIP 2026-09-21 00:43:30 +02:00
prettier.config.js ui rewrite. still ugly, but architecture is clean 2026-09-18 03:45:28 +02:00
README.md translation, WIP 2026-09-21 00:43:30 +02:00
tsconfig.json rebranding 2026-09-18 05:18:29 +02:00

Zaneku

A web-based AI harness: plugin-first, provider-agnostic, permission-gated, and recoverable. The core is thin - it owns the agent loop, session/conversation state, the permission gate, and the tool registry/pipeline. Everything behavioral (tools, model providers, persistence, file-state versioning) is a plugin.

Why Zaneku?

"We needed a name. We generated random names until we found one that sounded pronounceable, wasn't obviously taken, and didn't mean anything in particular. Zaneku was the one."

Built with TypeScript + Node. One runtime dependency - mustache (the Web UI's item/session-list templates are rendered from mustache at runtime); the backend uses only Node built-ins and a hand-rolled zero-dependency WebSocket server. typescript, @types/node, @types/mustache, esbuild, eslint, prettier, and playwright (used only by the headless UI e2e) are dev-only.

Requirements

  • Node.js >= 24 (global fetch, WebSocket, ESM, built-in node:sqlite).
  • Git on your PATH - only if you use the git change-management plugin (commit-per-turn, file-state undo/redo, fork branches). The core and all tools run fine without it.

Quick Start

npm install     # runtime dep (mustache, the UI template renderer) + dev deps
npm run build   # esbuild -> dist/
node dist/main.js

dist/ contains main.js (the single-file backend bundle, also the shared module every plugin imports via the bare @zaneku/core specifier), ui/ (the Web UI - index.html, app.js (a single-file IIFE bundle of ui/app.ts + the ui/src/* modules it imports), and css/main.css bundled from ui/css/), and plugins/<group>/<name>.js (one self-contained bundle per plugin, group layout preserved; loaded at runtime from dist/plugins/, config pluginsDir, scanned recursively).

On success it prints:

Zaneku ready
UI:      http://127.0.0.1:8765/
Plugins: sqlite-store, bash-tool, context-compaction, debug, echo-provider, edit-tool, git, openai-provider, read-tool, reference-plugin, test-provider, write-tool
Tools:   bash, edit, read, write

Open http://127.0.0.1:8765/ in a browser to use the chat UI (create a session, send prompts, approve ask-gated tool calls, /undo, /redo, fork). Stop with Ctrl+C (SIGINT/SIGTERM) for a graceful shutdown.

Configuration

Settings are read from two JSON files that are deep-merged:

  1. User-global - <home>/.config/zaneku/config.json (optional; skipped if absent).
  2. Local project - <repoRoot>/.zaneku/config.json (optional; skipped if absent).

The local file overwrites values from the global file; for lists, the local values are appended to (extend) the global ones. Objects (e.g. permissions, providers) merge recursively. Missing files are silently ignored; at least one file is not required (built-in defaults apply).

Key fields:

Field Meaning Default
host, port Bind address for HTTP + WebSocket UI 127.0.0.1, 8765
defaultModel Default model selection for new sessions, as provider/modelname -
providers.openai.baseUrl OpenAI-compatible endpoint (LM Studio / vLLM / Ollama / real OpenAI) http://localhost:1234/v1
providers.openai.model Model name for the openai provider -
maxStepsPerTurn Step (model + tool-call) budget per turn; a turn aborts when exceeded 50
permissions Per-tool allow/ask/deny rules (globs), default is ask see file
git.name, git.email Identity used by the git plugin's commits -
pluginsDir, dbPath, uiDir Where the plugin bundles, the store DB file, and the UI assets are found dist/plugins (scanned recursively; plugin files live in group subdirectories), dist/plugins, dist/ui
store Name of the persistence backend to activate (key into the StoreRegistry). Persistence is a store plugin, not a core dependency - the shipped default is the sqlite store plugin. sqlite
storePlugin Compiled module path of the store plugin to load before the regular plugin scan (explicit path, not discovered by the scan). The plugin self-registers under its name. <pluginsDir>/storages/sqlite-store.js (i.e. the bundled sqlite plugin)
debug LLM-response debug logging (debug plugin): enabled turns it on at startup (also toggle at runtime with `/debug on off); file` overrides the log path. Off by default.

A commented-out example of the step-budget override (config files are strict JSON - no comments - so this shows the key you would set on its own line):

{
  // uncomment and edit to override the per-turn step budget (default 50)
  "maxStepsPerTurn": 50
}

Environment overrides: ZANEKU_HOST, ZANEKU_PORT, ZANEKU_DEFAULT_MODEL. The OpenAI provider also honors OPENAI_BASE_URL, OPENAI_API_KEY, and ZANEKU_OPENAI_MODEL.

Using a real model (OpenAI-compatible)

In .zaneku/config.json (local or global), point providers.openai.baseUrl at any OpenAI-compatible server (LM Studio, vLLM, Ollama):

"providers": { "openai": { "baseUrl": "http://localhost:1234/v1", "model": "google/gemma-4-12b-qat" } },
"defaultModel": "openai/google/gemma-4-12b-qat"

Model selection (provider/modelname)

A model is addressed as provider/modelname (split on the first slash). Set it:

  • At session start - the UI/API session:create accepts a model spec.
  • At runtime - type /model openai/google/gemma-4-12b-qat in chat, or /model alone to see the current selection + available providers. Chat slash commands are registry-driven: /help lists every registered command (core + plugins, e.g. /refinfo from the reference plugin), and unknown slash words fall through to the model.
  • As a default - defaultModel in .zaneku/config.json.

Per-turn precedence and the full provider/modelname addressing rules (split on the first slash, bare-token disambiguation) are documented in docs/03-05-model-providers.md (Model Addressing). Forks inherit their source's model. Changing it never touches file state and applies from the next turn.

Documentation

The full design documentation lives in docs/index.md, which is a table of contents pointing to one file per topic. Start there:

Each docs/*.md file states its own status (implemented / planned) where it matters; state.md is the single source of truth for "is this built?". The rest of the component docs (hooks, plugins, tools, permissions, change management, MCP, data model, scope, open questions, e2e test design) are linked from docs/index.md.

Testing

npm test           # = build + unit tests + backend e2e + UI e2e
npm run test:unit  # unit only
npm run test:e2e   # backend e2e only (live WebSocket protocol)
npm run test:ui    # UI e2e only (headless browser)

All three are hermetic: they run on a dedicated deterministic test dummy provider (plugins/providers/test-provider.ts) in a scratch workdir and need no network or external model. Real endpoints are validated separately (ad-hoc), not by the committed tests.

The backend e2e drives the live WebSocket + /api protocol against a spawned dist/main.js. The UI e2e (test/ui-e2e.test.mjs) is headless: it drives the actual compiled dist/ui in a browser against the real backend and asserts on the visible DOM (session creation, turn streaming + the response rendering, multi-session content, and switching back). It launches the system Chromium via Playwright (executablePath: /usr/bin/chromium-bin); override with CHROMIUM_BIN=/path/to/chromium. Requires playwright (dev dep - see docs/08-end-to-end-tests.md for the full design).

Project layout

src/core/    core: agent loop, sessions, hooks, permissions, tool registry/pipeline, providers, MCP, WS server
src/main.ts  bootstrap - wires everything together and starts the server
plugins/     one plugin per concern (TypeScript, compiled to dist/plugins/ at build time),
             grouped by base class - the runtime loader discovers them recursively:
   plugins/tools/       ToolPlugin:      read-tool, edit-tool, write-tool, bash-tool (the standard tools)
   plugins/providers/   ProviderPlugin:  openai-provider (OpenAI-compatible), echo-provider (reference), test-provider (test dummy)
   plugins/storages/    StoreProviderPlugin: sqlite-store (persistence backend)
    plugins/hooks/       plain Plugin:    reference-plugin (demo hooks/permissions/UI/command), git (file-state over git), context-compaction (context-window management), debug (LLM-response logging to debug.log, off by default)
  ui/          Web UI - served by the core. Layered, observer-driven TypeScript:
               `app.ts` (entry) + `ui/src/{app,controller,core,io,render,store}/` -
               one way: server->WS->controller->store->observers->views;
               click->actions->store/send. `types/` are pure .d.ts (protocol +
               view models, no runtime bytes). `css/` is modular CSS (main.css
               imports the group files incl. variables.css tokens). See
               docs/03-10-web-ui.md for module-by-module responsibilities.
  test/        test suites: zero-dep unit + backend e2e (live WS proto) + UI e2e
               (headless Playwright over the real dist/ - see test/ui-e2e.test.mjs)
  docs/        design docs (architecture, agent loop, tools, providers, plugin API, scope, state)
 dist/        build output: main.js (single-file backend bundle), ui/ (Web UI assets:
              index.html, app.js (single-file IIFE bundle of ui/app.ts + ui/src/*),
              css/main.css bundled from ui/css/), plugins/ (one self-contained bundle
              per plugin, same group layout)

How it fits together (one turn)

user message -> [turn_start hooks] -> build context (+compaction) -> select provider/model
  -> provider.chat() streams normalized events (tokens / thinking / tool_calls / usage)
      +- for each tool_call: permission gate (allow/ask/deny) -> tool_before -> execute
                             -> tool_after (can scrub/replace the result) -> append to context
  -> final answer -> [turn_end hooks] -> file-state commit point (git plugin)
  -> persist + advance conversation pointer -> stream events to the UI
(when the per-turn step budget trips, the turn is paused and can be resumed
 with /continue or the inline "continue" button - no new user message)

Every internal step is a hook point; plugins attach behavior by overriding base-class methods. The permission gate always runs before any tool executes, and tool_after can rewrite what the model sees - e.g. scrubbing secrets out of file reads before they reach the LLM.