ADR-0004: Runtime is Bun + TypeScript

  • Status: Accepted
  • Date: 2026-05-21
  • Deciders: @karasu
  • Supersedes:
  • Superseded by:

Context

kaged needs a runtime and a language. The constraint set:

  • One binary on a small Linux host. Operators install kaged on whatever low-resource Linux box they have (often ARM64) and run it via systemd. The runtime must be installable as a single artifact and start fast.
  • HTTP/WS server. Per ADR-0002, the daemon serves a web UI and a WebSocket protocol for streaming. This is the primary surface, not a side feature.
  • PTY broker. The daemon multiplexes real pseudo-terminals over WebSocket. PTYs need OS-level interaction (fork, exec, ioctl), and the language/runtime must expose them comfortably.
  • Subprocess supervision. Sandboxed subagents run as child processes. The runtime must spawn, monitor, signal, and reap them cleanly.
  • SQLite, by default. Per ADR-0005, the storage is SQLite. The runtime must talk to it without C-extension build pain on every supported OS.
  • Plugin SDK in the same language as the daemon. Plugins are subprocess-by-default (ADR-0008), but the SDK that authors target is a real, ergonomic, typed API. That language is the project's language.
  • Frontend in the same monorepo. The web UI ships from packages/web/ (see vision doc), built and bundled by the same toolchain that serves it.
  • Doc-first then TDD posture. Per ADR-0003, every spec lands first, then failing tests, then code. The test runner must be fast and built-in — bun test cycle has to be subsecond.
  • Single operator working on their own box. Optimize for that developer's local cycle, not enterprise CI throughput.

The shortlist of viable runtimes:

  1. Node.js + TypeScript (via ts-node, tsx, or pre-build) — the default. Mature.
  2. Bun + TypeScript — newer, native TS, batteries-included.
  3. Deno + TypeScript — secure-by-default runtime with TS first-class.
  4. Go — single static binary, excellent concurrency, less ergonomic for frontend reuse.
  5. Rust — single static binary, top-tier safety, slowest iteration cycle.

package.json already lists @types/bun as a devDependency. AGENTS.bun.md already lays out the Bun-first conventions for any agentic contributor working on the repo. This ADR ratifies what is already de facto in place and documents why.

Decision

The kaged daemon, web UI, DSL parser, sandbox supervisor, plugin host, and plugin SDK are all written in TypeScript and run on Bun.

Concretely:

  • Daemon runtime: Bun. Production deploys run bun ./packages/daemon/index.ts or an equivalent compiled artifact (bun build --compile).
  • Frontend bundling: Bun's bundler via Bun.serve() HTML imports (no Vite, no Webpack). See AGENTS.bun.md.
  • Test runner: bun test. No Jest, no Vitest.
  • Package manager: bun install. Workspaces via bun.lock.
  • SQLite client: bun:sqlite built-in. No better-sqlite3.
  • WebSocket / HTTP: Bun.serve() built-in. No express, no ws.
  • Process spawning: Bun.spawn and Bun.$ for sandbox supervision and plugin subprocess hosting.
  • Type system: TypeScript strict mode. No JavaScript files in production code.

The minimum supported Bun version is whatever is current at v0 cut. Bun version pinning lives in package.json "engines" and CI.

A thin Node compatibility shim is acceptable only for plugins that need to run under Node (legacy plugin authors). The daemon itself is Bun-only.

Consequences

What this commits us to

  • Bun's stability is now kaged's stability. When Bun ships a regression, we ship it. We pin versions and test against new minor versions before adopting.
  • Single-file conventions. Bun's HTML-imports-as-routes, Bun.serve()'s combined HTTP+WS surface, and bun:sqlite mean kaged uses Bun's idioms, not Node's. New contributors coming from Node need to read AGENTS.bun.md.
  • Frontend toolchain stays in Bun's bundler. No Vite escape hatch. If Bun's bundler can't do something we need (HMR edge cases, source-map fidelity, SSR), we work around it or push upstream.
  • Bun's PTY story is what we have. If Bun.spawn's PTY support is insufficient, we use a Node-compat shim or write native bindings — but we don't switch runtimes.
  • AGENTS.bun.md is normative documentation. It's not just a contributor hint; it's the binding contract for what APIs and patterns kaged uses.

What this forecloses

  • No Node-isms. No express, no dotenv, no pg, no ioredis, no better-sqlite3, no ws. Even if a contributor knows them better, we use Bun's primitives.
  • No "we'll switch to Deno later" hedge. Bun's idioms (Bun.serve, bun:sqlite) are not portable to Deno without a rewrite of every IO call.
  • No mixed JS/TS production code. Type errors are bugs; suppressing them with as any or @ts-ignore is forbidden (per the project coding rules; this ADR ratifies the language-level half).
  • No Go/Rust temptation. kaged is not going to become "the Bun version" of a Go/Rust daemon. The decision is one-way.

What becomes easier

  • One language end-to-end. Daemon, frontend, DSL parser, plugin SDK, tests — all TypeScript. No language boundary to cross between server and client. Types can be shared via a common package.
  • Fast iteration. bun --hot for HMR, bun test for sub-second test cycles, bun build --compile for single-binary production artifacts. The cycle is measured in milliseconds, not seconds.
  • Zero build config. No tsconfig juggling for ESM/CJS, no Babel, no Webpack. Bun reads .ts/.tsx directly.
  • Native SQLite. bun:sqlite is a built-in, not a native-build C extension. No node-gyp headaches on ARM64 or other constrained hosts.
  • Single binary deploys. bun build --compile produces a self-contained executable; deploy is scp + systemctl restart.
  • AI-friendly. AI models read TypeScript fluently. A single-language codebase is easier for a model to reason about and modify, which matters because kaged is meant to be modifiable from inside kaged itself eventually.

What becomes harder

  • ARM64 platform support. Bun has historical ARM64 wobbles. We must test on real ARM64 hardware before every release, not assume the x86_64 build "should work."
  • Hiring / contributor pool. Node contributors outnumber Bun contributors. New volunteers will hit Bun-specific idioms and need orientation.
  • Library compatibility. Some npm packages assume Node-only APIs (node:fs patterns, Buffer quirks, native modules). When we hit one, we either use Bun's native equivalent, find an alternative, or shim — we do not switch runtimes.
  • Tooling parity for some niches. Profilers, APM, certain code-mods are more mature on Node. We accept the gap and improve our own where it matters (e.g. flame graphs via bun --inspect).
  • Long-term bet. Bun is younger than Node. If Bun stagnates, switching cost is real. We're betting it doesn't, and we monitor.

Alternatives considered

Alternative A — Node.js + TypeScript

Why tempting: Largest ecosystem. Most mature tooling. Most contributors already know it. No platform-support worries on ARM64.

Why rejected: Slower local cycle (TS compilation step, separate bundler, separate test runner). The amount of package.json / tsconfig.json / bundler config Node requires is friction we don't need. Bun is what Node could have been if it had been designed in 2020 with TypeScript as a first-class concern. For a fresh codebase, the inertia of Node's tooling has no payoff.

Alternative B — Deno + TypeScript

Why tempting: TypeScript-first, permissions model aligns with our sandbox-by-default ethos, single-binary deploy, modern URL imports.

Why rejected: Deno's HTTP/WS APIs are clean but its ecosystem is thinner than Bun's, and bun:sqlite has no real Deno equivalent (you reach for FFI or a third-party module). The frontend story is weaker — Deno doesn't bundle HTML+CSS+TSX with the same fluency. Deno's permission system is interesting but conflicts with kaged's own sandbox model (we have our own allowlist concept; we don't want two competing ones). Smaller community than Bun in 2026.

Alternative C — Go

Why tempting: Single static binary by default. Excellent concurrency primitives. Boring, stable, fast.

Why rejected: The frontend would have to be a separate language and toolchain. Plugin SDK in Go means plugin authors write Go; or we ship a TypeScript SDK in a separate language anyway. The "one language end-to-end" benefit is the largest single argument for TS, and Go can't deliver it. Also: Go's HTTP stack is fine but its HTML/JSX templating is awkward for a React-first web UI.

Alternative D — Rust

Why tempting: The strictest safety guarantees. Excellent for sandbox enforcement, where bugs are P0. Single binary. Best-in-class perf.

Why rejected: Iteration speed. kaged is in pre-alpha and most of the decisions are still being discovered. Rust's compile times and ownership friction slow exploration to a crawl. We can rewrite specific hot paths in Rust later (FFI from Bun is fine) without committing the whole codebase to it. Same one-language-end-to-end argument as Go — Rust on the frontend is possible (Leptos, Dioxus) but not where the kaged team's leverage is.

References