Skip to content
←Return to Projects
Case Study

KIWI

Autonomous local AI agent and community of voices living in The Grove with private memory.

The Problem

Autonomous AI agents often suffer from leaky memory isolation, unpredictable side-effects on host systems, and lack of human-in-the-loop governance for high-stakes file operations and external device actions.

My Role

Architected and implemented the cognitive engine ("The Grove"), fail-closed host action safety policies, immutable exact-action plan ledgers, multi-device agent companion protocol, and unified multi-channel gateways.

Highlights

  • Community of six voices living in The Grove with local memory running on the user's machine
  • Local cognitive core ("The Grove") separating owner memory from credentialed client sessions
  • Fail-closed security policy requiring immutable exact plans with rollback for all destructive actions
  • DPAPI-secured Windows companion agent with local execution receipts and anti-re-execution guarantees
  • Multi-modal interaction surface: Next.js Web UI, Voice HUD (:4321), and Telegram bot gateway with inline approvals
  • Deterministic audit ledger tracking every state mutation, file movement, and memory association

Stack

TypeScriptNext.js 15Node.jsSQLiteDrizzle ORMDPAPITelegram Bot APIDocker

Constraints

  • Host safety is non-negotiable: no autonomous file deletion or batch reorganization without human-confirmed exact plans.
  • Offline-first engine: core cognitive loops and memory databases must run purely locally without cloud dependencies.
  • Multi-tenant memory isolation: administrative sessions and API token holders must never access the owner private mind context.

System Architecture

Interaction Channels
Next.js 15 Web App (:3333)
Voice HUD (:4321)
Telegram Bot
Embed Bridge (:3234)
Gateway & Protocol
Private Grove API (:8431)
Device Control API
DPAPI Token Auth
Cognitive Engine ("The Grove")
Mind Core
Behavior & Persona
Memory Tiering (STM/MTM/LTM)
Skill Registry
Execution & Safety
Host Action Policy
Exact-Action Ledger
Backup & Rollback Ledger
Distributed Node Runtime
Windows Companion Daemon
Local File Executor
SQLite / Drizzle State

Key Trade-offs

The decisions worth defending — what I chose, what I turned down, and why.

File management execution model

Chose

Immutable two-phase exact-action plans with hash verification and dry-run preview

Rejected

Direct agent tool execution with broad shell permissions

Agents making speculative shell or file modifications risk catastrophic data loss; two-phase plans guarantee human oversight and rollback capability.

Engine-to-web communication architecture

Chose

Dedicated loopback HTTP/SSE gateway with bearer token authentication

Rejected

Monolithic single-process Next.js application

Decoupling the AI engine process from the Next.js frontend ensures background monitoring, telegram polling, and long-running skills survive web redeployments.

Device pairing security model

Chose

Windows DPAPI machine-bound tokens with polling architecture

Rejected

Inbound SSH or remote socket servers on client machines

Inbound listening ports expose clients to local network scans; outbound HTTPS polling behind DPAPI keeps remote companion devices secure.

What I'd Do Differently

An honest retrospective — the stuff I'd change with more time, more users, or a second pass.

  1. 1Implement automated regression tests for memory graph decay sweeps earlier in the development lifecycle.
  2. 2Consolidate SQLite schema migrations between better-sqlite3 and pg-schema under a single unified migration runner.
  3. 3Add fine-grained telemetry dashboards to observe cognitive latency across multi-turn reasoning chains.

Interactive Autonomous Agent Shell

Test live KIWI autonomous agent supervisor routines (agent status, grove ping, mcp tools, help) in the emulated shell below.

icelegends-interactive-shell ~ [kiwi]
History: ↑ / ↓
kiwi@the-grove:~/voices$

Technical Deep-Dive

Architecture, specifications, and implementation details.

P2 — Distribution and CLI client

Revision 21, 2026-09-09. First real P2 slice, chosen because the operator has no Windows machine available this session (the P1 Windows device pilot's own physical acceptance stays blocked) and package-name ownership (npm install kiwi) is unresolved — see "Confirmed against the npm registry" below. Local code and automated verification; no production rollout, no package published.

#Confirmed defect and fix — Pro subscribers could not use the CLI they pay for

web/src/app/(dashboard)/dashboard/cli/page.tsx is gated requirePlan('pro') and its copy reads "Pro plan — Kiwi in your terminal, next to your code"; the shipped client web/public/kiwi.mjs itself prints "No machine token issued — CLI access requires the Pro plan." on failure; web/src/lib/utils.ts's PLANS.pro.features explicitly lists "CLI access — connect Kiwi to your terminal" as a Pro-tier feature. All three independently agree the bar is Pro. But POST /api/os/login (web/src/app/api/os/login/route.ts) only issued a machine token when plan === 'enterprise' && status === 'active', or when the account had no subscription row at all (a legacy exemption). An active, paying Pro subscriber logged in successfully and received no token — unable to use the feature their plan advertises.

Fixed by using allowedForPlan(plan, status).cli (web/src/lib/plan-guard.ts) — the same rule that already, correctly, gates the /dashboard/cli page (paid = plan is pro or enterprise, status active) — instead of the route's own narrower, Enterprise-only inline check. The !sub legacy exemption is unchanged. This was verified not to widen an unrelated Enterprise-only feature: web/src/app/api/download/iso/route.ts's hasEnterprise() independently re-queries the caller's subscription on every request rather than trusting that a valid agent-key token implies any particular plan, so a Pro-issued token still gets 403 Kiwi OS is an Enterprise feature there. /api/chat and /api/kiwi/status (the two endpoints kiwi.mjs actually calls) resolve identity via currentUserIdFrom with no plan re-check at all — any valid agent-key token already worked for them; the only real gate was ever at token issuance.

New tests: web/tests/os-login-entitlement.test.ts (7 — active Pro receives a token that genuinely authenticates /api/chat; active Enterprise still works; a legacy no-subscription account still works; canceled Pro and active Free receive no token; wrong password is rejected regardless of plan; a Pro-issued token still gets refused by the independently-gated ISO endpoint).

#Client hardening — web/public/kiwi.mjs

The shipped, zero-dependency, curl-downloaded CLI client had four issues, none previously covered by any test:

  • main() ran unconditionally at module import, making the file untestable without triggering real network calls and process.exit. Now guarded: const isMain = argv[1] && import.meta.url === pathToFileURL(argv[1]).href; if (isMain) main()... — importing the module (as the new test file does) runs nothing.
  • The password prompt used plain readline, which echoes every typed character to the terminal. askHidden() puts stdin in raw mode and consumes it manually (Enter submits, Backspace/DEL edits, Ctrl+C exits like the rest of the CLI), falling back to the existing visible prompt when stdin isn't a TTY (nothing to hide from a non-interactive caller, and raw mode requires an interactive terminal).
  • KIWI_API let an operator override the server origin to anything, including plain http://, while the response carries a long-lived Bearer token — a cleartext origin would leak it. resolveApiOrigin() now requires HTTPS unless the host is a loopback address, mirroring the exact rule the Windows companion's serverOrigin() (src/devices/agent.ts) already enforces for the same reason. Every network call also gained a 15s timeout and redirect: 'error', again matching the companion.
  • Server-controlled strings (error messages, chat responses, status fields) were printed to the terminal unsanitized. clean() strips the same control-byte range (\x00-\x08, \x0b-\x1f, \x7f) the companion's own clean() in src/devices/agent.ts strips — removing the ESC byte that turns following bytes into a live escape sequence, not a claim of removing every visible artifact of one.

Also added: --version (was missing; kiwi-device in the companion already has one).

New tests: web/tests/kiwi-cli-client.test.ts (5 — rejects non-HTTPS/non-loopback origins, accepts HTTPS and loopback HTTP, defaults to the production origin, strips control bytes, exports a version string). The interactive TTY prompt and the real network calls are not unit-tested — same boundary as the companion's executeFileEdit (tested) versus its readline-driven shell (not).

#Packaging gate — packages/companion and a stale-artifact fix

packages/companion/dist/ (the compiled @kiwi-local/companion companion) was stale coming into this session — it predated the revision 19 restore mode and revision 20 markAcknowledged reconciliation fix, since nothing in this session's earlier work had rebuilt it (npm run test/typecheck only check src/, never the compiled companion output). Recompiled (npx tsc -p tsconfig.companion.json) and smoke-tested (--help/--version). packages/companion/dist/ is gitignored — a build artifact, not something to commit; keeping it in sync is a build-time concern, which is what this section's new script addresses going forward.

New scripts/verify-companion-package.mjs (npm run verify:companion) is a packaging gate that runs on any OS, since packing/inspecting a tarball is platform-neutral even though the package itself only installs on win32 (package.json's os:["win32"], required by the DPAPI credential store src/devices/credentials.ts needs). It checks, in order: (1) the package builds cleanly from src/devices/*; (2) no preinstall/install/postinstall/prepare/prepublish/prepack script exists — P2's plan explicitly forbids starting an interactive application from an npm install lifecycle hook, and this makes that a checked invariant rather than a one-time claim; (3) npm pack --dry-run --json's real file listing actually contains the declared bin entry and every declared files entry — not just what package.json claims; (4) the built --version output matches package.json's version field, the exact class of drift the stale-dist issue above was; (5) --help mentions every documented mode. On win32 only, it additionally does a real npm pack → npm install -g <tarball> --prefix <isolated temp dir> → --help → npm uninstall → confirms the prefix's bin is empty round trip; on any other OS this step is printed as skipped with the reason (npm's own os field refuses the install there — a correct refusal, not a gap to work around). Run on this Windows machine: all 10 checks passed, including the real install/uninstall round trip (.verify-work-style JSON report printed to stdout, exit code reflects pass/fail).

Also removed the root package.json's "bin": {"kiwi": "./src/cli/kiwi.ts"} — the repository is "private": true and was never installed via that path; the field pointed at a raw .ts file with no shebang, which cannot actually run as a bin script, and the real, only supported way to run that file is npm run cli (node --import tsx src/cli/kiwi.ts, src/cli/kiwi.ts's own docstring). This did not change how the dev CLI is invoked — it removed a declaration that never worked and was not the file's own documented entry point.

#What src/cli/kiwi.ts is, and is not

src/cli/kiwi.ts (npm run cli) is a separate, pre-existing, single-operator terminal chat tool that talks directly to createKiwi() (the local engine object in-process) on this machine — no account login, no remote API call, not multi-tenant. It is not a candidate to become the P2 distributable product without first deciding to make it call the web's authenticated API remotely instead of the in-process engine — that is itself a real design decision, not something this revision made.

#Verification

Engine: 138 tests (137 passed + one optional semantic skip; unchanged — src/ outside src/devices/ untouched). Web/mind: 249 tests in 39 suites (+12 tests, +2 suites: os-login-entitlement.test.ts, kiwi-cli-client.test.ts). Both typechecks passed. npm run verify:companion: 10/10 checks passed on this Windows machine, including the real install/uninstall round trip. No production rollout; os/login's entitlement fix has not been deployed.

#Remaining P2 work

Full coding/diff-approval TUI (deliberately out of scope for this revision — it is dev_task's eventual safe replacement, a separate, security-sensitive design effort); npm package-name/registry ownership (kiwi is already taken by an unrelated party on the public registry, confirmed via npm view kiwi; the @kiwi-local scope is free but claiming it is an npmjs.com account action, not code); browser-login TUI experience beyond the existing kiwi.mjs login flow; public distribution and any real clean-machine (not isolated-prefix) install/update verification; code review; controlled rollout.

Sources: web/src/app/api/os/login/route.ts, web/src/lib/plan-guard.ts, web/src/lib/agent-keys.ts, web/src/app/api/download/iso/route.ts, web/public/kiwi.mjs, packages/companion/, scripts/verify-companion-package.mjs, Windows pilot, checkpoint.

#Revision 38 (2026-09-10): kiwisan published on npm; a live production login bug found and fixed; slice 1 of the full-screen TUI

The npm package-name question, actually resolved. kiwi (bare, unscoped) is permanently taken on the public registry by an unrelated, actively-maintained package (a template engine) — confirmed not obtainable (no abandonment, no trademark dispute npm would act on). At the operator's direction, claimed kiwisan instead and published it live under their own account: packages/kiwi-cli/ wraps the already-hardened kiwi.mjs script as a real global kiwisan bin, not a placeholder. npm install -g kiwisan verified end to end against the real registry. packages/companion stays private/local per an explicit operator decision — nothing from it was published.

A real, live, production-breaking bug found and fixed along the way, via an Opus investigation pass (per this session's standing workflow rule — bug reports always get an Opus read-only diagnosis before a fix, not a self-diagnosed patch): web/src/middleware.ts's PUBLIC_API allowlist never included /api/os/login — every unauthenticated login attempt (the only kind that endpoint ever receives, since its whole purpose is to issue the first token) was intercepted at the middleware layer with a generic {"error":"Unauthorized"} before the route's own email+password/bcrypt logic ever ran. A chicken-and-egg deadlock: you need a token to get a token. The CLI's login has never worked in production, for anyone, until this fix. Not a plan/entitlement issue (the account that hit it was already enterprise/active) and not the route's own logic (its real error strings are "Invalid credentials"/"Email and password are required", never "Unauthorized" — proof the request never reached it). os-login-entitlement.test.ts couldn't catch this: it calls route.POST() directly, bypassing middleware entirely. New web/tests/os-login-middleware.test.ts asserts the unauthenticated POST now passes through, and that an unrelated os/* path is still protected. Fixed by adding the exact string '/api/os/login' (not a /api/os/ prefix) to PUBLIC_API. Deployed to production and verified live via curl ({"error":"Invalid credentials"} for bad credentials, not "Unauthorized").

Two real, live Windows crashes in kiwi.mjs itself, found and fixed the same day, by the operator hitting them in a real terminal: (1) login() read the email via a readline.Interface (created/closed per call) and the password via direct stdin.setRawMode() manipulation immediately after — on Windows, closing a readline interface's stdin handle and then toggling raw mode on the same handle races libuv's own async handle teardown (Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src/win/async.c). Fixed by routing both prompts through one raw-mode implementation (promptLine), never touching readline.createInterface during login. (2) The same crash still reproduced afterward, from a different cause: process.exit() racing any async handle's teardown — a raw-mode toggle, a fetch timeout timer, a socket — not just readline specifically, right after a failed login printed its error and called exit(1) following an awaited fetch(). Fixed by never calling process.exit() anywhere in the file: every exit path now throws CliExit(code), caught once at the very bottom, which sets process.exitCode and lets Node exit naturally once the event loop drains.

Slice 1 of the operator's full-screen-TUI request, scoped by an Opus pass then built: kiwisan/kiwi.mjs now enters a persistent, hand-rolled-ANSI full-screen frame (alternate screen buffer, the same 16×16 pixel-art character the web /os desktop shows, a scrollback transcript, a bordered input line, a status bar) after a successful login or when run bare in a real terminal — piped input, piped output, and one-shot kiwisan "message" are entirely unaffected, exercising the same code paths as before. Chat-only: no file edits, no shell execution — that stays a deliberately separate, later, security-reviewed slice, meant to render through the already-shipped approval executor (src/devices/local-executor.ts, from the P1 device pilot) rather than a new one. Persona pinned to forge (Papa Ko) by default via the already-working @forge <message> mention syntax (chat-router.ts's detectIntent) — no server change needed for that part; /persona <name> switches among all six. See docs/adr/0011-kiwisan-tui-hand-rolled-ansi.md for why this is hand-rolled ANSI and not a terminal-UI library (web/public/kiwi.mjs's curl-distribution channel structurally cannot carry npm dependencies, and libraries in the ink family drive the exact raw-mode stdin surface that just crashed twice). New scripts/verify-cli-mirror.mjs (npm run verify:cli) — the two kiwi.mjs copies had no automated sync check before this; it now fails if they diverge beyond their known channel-name text, or if VERSION drifts from packages/kiwi-cli/package.json (it already caught exactly that drift once while writing this slice).

Verification: npm run verify:imports — 678 files clean. npm run verify:cli — clean. web/tests/kiwi-cli-client.test.ts extended to 15 tests (was 5) covering the new pure functions (renderSprite, wrapToWidth, composeFrame, visibleLen, padRight) — including a regression test for a real infinite-loop bug wrapToWidth had on its first version (a word longer than the pane width, when the current line was already empty). npm run test:mind — 52 suites / 364 tests, all green. npx tsc --noEmit (web) clean.

~ End of Document ~