Kunkun
Building Extensions

Backend Processes

Spawn a managed, long-lived child process and talk to it over typed RPC.

Sometimes a command isn't enough — you need a persistent process: a PTY, an SSH connection pool, a native scanner, a long-running server. spawnBackend launches a managed Node/Deno/Bun process from your extension and gives you a typed RPC handle to it. This is the seam that also lets extensions ship as both a CLI and a plugin.

The two halves

Frontend — spawnBackend

import { spawnBackend } from "@kunkunsh/sdk";

interface TerminalBackend {
  open(cmd: string): Promise<string>;   // returns a session id
  write(id: string, data: string): Promise<void>;
}

const conn = await spawnBackend<TerminalBackend>({
  scriptPath: "$EXTENSION/dist/backend.js",
  runtime: "node", // "auto" | "node" | "bun" | "deno"
});

const sessionId = await conn.api.open("bash");
await conn.api.write(sessionId, "ls\n");
await conn.destroy(); // kill the process

spawnBackend returns { api, backendId, destroy }api is a typed proxy over kkrpc.

Backend — exposeBackend

// src/backend.ts  (bundled to dist/backend.js)
import { exposeBackend } from "@kunkunsh/sdk/backend";
import { spawn } from "node:child_process";

const facade: TerminalBackend = {
  async open(cmd) { /* start a pty, return id */ return "s1"; },
  async write(id, data) { /* write to the pty */ },
};

exposeBackend<TerminalBackend>(facade);

You can also pass a localAPI to spawnBackend so the backend can call back into the frontend (bidirectional kkrpc).

Manifest permission

Declare exactly which script may be spawned, and its runtime:

{
  "permissions": [
    { "permission": "backend", "allow": [{ "script": "$EXTENSION/dist/backend.js", "runtime": "node" }] }
  ]
}

Capabilities

Extra process powers are declared per backend scope under capabilities. Every one follows the same shape — omit = none, true/"all" = blanket, array = scoped list:

{
  "permission": "backend",
  "allow": [{
    "script": "$EXTENSION/dist/backend.js",
    "runtime": "auto",
    "capabilities": {
      "subprocess": "all",                          // or ["ps", "sysctl"]
      "env": true,                                  // or ["HTTP_PROXY"]
      "sys": ["loadavg", "cpus", "systemMemoryInfo"],
      "nativeAddons": ["node-pty/**/*.node"],
      "ffi": ["native/libscanner.dylib"]
    }
  }]
}
CapabilityFormWhat it grants
subprocess"all" | string[]Child processes. A program list scopes Deno's --allow-run; Node can't narrow it (list ⇒ Deno-only).
envtrue | string[]Env access. true enumerates the (host-curated, secret-free) env; a list also copies those keys through from the host env. Needed by deps that spread process.env.
systrue | string[]OS introspection (Deno --allow-sys kinds: loadavg, cpus, systemMemoryInfo, networkInterfaces, hostname, osUptime, …).
workersbooleanWorker threads.
nativeAddonsstring[] (globs)Packaged .node artifacts the backend loads.
ffistring[]Native libraries loaded through Deno.dlopen().
unsandboxedbooleanEscape hatch — no process sandbox. Last resort.

Runtimes & sandboxing

A runtime is eligible only if it can enforce every declared capability at least as narrowly as declared; otherwise it's rejected and auto moves to the next candidate.

RuntimeNotes
DenoBest sandbox — scoped --allow-read/write, per-domain --allow-net, --allow-run/--allow-env/--allow-sys (blanket or scoped). Preferred by auto.
Node v25+Node permission model. Blanket env: true/sys: true are implicitly satisfied (Node never gates them); scoped env/sys/subprocess, domain-scoped net, and FFI are rejected unless capabilities.unsandboxed.
BunNo sandbox — allowed only with capabilities.unsandboxed.

"auto" picks Deno → Node. Declaring a scoped sys/env/subprocess list therefore steers auto to Deno, since Node can't enforce the narrower grant. The process sandbox applies your manifest scopes as runtime flags.

Native addons don't inline

A .node addon can't be bundled into a JS file. Package the addon's loader + platform .node files alongside dist/backend.js, and allow them via capabilities.nativeAddons. Or lazy-load the native feature so the extension still works without it.

Building the backend

Bundle the backend entry as a standalone Node/Deno script:

await Bun.build({
  entrypoints: ["./src/backend.ts"],
  outdir: "./dist",
  target: "node", format: "esm",
  // keep native addon packages external and copy their files into dist/
});

Real examples

  • wterm-terminal-demo — a custom view backed by a Node PTY over spawnBackend
  • kkterminal / space-lens — production backends shared with a CLI; see CLI + Plugin

On this page