Skip to content

Lifecycle ​

What the runtime does to your plugin, from boot to shutdown. Understanding this explains why handlers must register synchronously, when serveReady() matters, and why a crash loop quarantines a plugin.

1. Discovery ​

At boot the runtime reads server.json → installed_plugins: string[]. For each slug it resolves a folder (core plugins first, then user plugins) and reads manifest.json. A slug with no resolvable manifest is skipped with a warning — it doesn't block boot. A plugin marked disabled in settings is also skipped.

The list is read only at boot. Adding a slug to installed_plugins requires restarting the server through the desktop/plugin-management flow, not a hot reload. The host backend decides whether that means recreating a Docker container or reconciling a native systemd unit.

2. Database & migrations ​

Before spawning, the runtime prepares the plugin's data directory (/data/plugins/<slug>/, mode 0700) and runs the SQL files in migrations/ in filename order. The SQLite database opens in WAL mode on first use. If a migration throws, the plugin is skipped with an error.

3. Spawn ​

The backend is launched as its own subprocess:

Bun.spawn(["bun", "--smol", "run", "<backend entry>"], {
  cwd: "<plugin folder>",
  stdin: "pipe", stdout: "pipe", stderr: "pipe",
  env: {
    PLUGIN_SLUG, PLUGIN_API_VERSION, PLUGIN_DATA_DIR,
    NODE_OPTIONS: "--max-old-space-size=256",  // memory guard (cgroup is authoritative)
    NODE_PATH,                                 // where the runtime-seeded @uncorded/* lives
  },
})
  • --smol runs Bun in low-memory mode (more frequent GC).
  • stdin/stdout are owned by the IPC transport — don't read stdin or write raw protocol to stdout. Unprefixed stdout and all stderr are captured as logs.
  • On Linux this command is wrapped by the sandbox launcher, which applies the seccomp + Landlock jail before exec and refuses to spawn if the jail can't be established (see Security model). Everything above is the same either way.
  • @uncorded/* resolves through the seeded NODE_PATH, so the SDK always matches the runtime; your own third-party imports resolve against the plugin folder's node_modules (packaging).

4. The ready handshake ​

createPlugin() sends { "type": "ready" } to the runtime as the last thing it does. The runtime waits up to 30 seconds for it; no ready frame in that window is a HANDSHAKE_TIMEOUT and the spawn fails.

ready only proves the process is alive and the SDK is wired — not that your caches are warm. For most plugins that's enough and the runtime starts routing requests immediately.

5. The optional serve-ready handshake ​

If your plugin needs to hydrate state before it can answer requests (warm a cache, prefetch from an external service), opt into the two-stage handshake:

json
{ "serve_ready_handshake": true }

With it set, the runtime registers the plugin as not-ready-to-serve and publishes runtime.plugin.ready when you signal completion:

ts
// after caches are loaded, member lists fetched, etc.
plugin.serveReady();

Without the opt-in, serveReady() is a harmless no-op (the plugin is treated as serve-ready the moment it spawns).

What this does today. The readiness flag is real end to end — the runtime tracks it, exposes it on the plugin list, and the shell folds the runtime.plugin.ready event. What the shell does with it right now is self-heal: if the sidebar failed to build or came back empty while your plugin was still starting, the ready event triggers a refetch, so your items appear as soon as you're actually able to answer.

What it does not yet do is grey out or hide items during startup — a not-yet-ready plugin's rows are rendered like any other. So don't rely on the flag to hide a broken state: if a request can arrive before you can answer it, the handler still has to answer it — return an empty list or a typed "still starting" error rather than throwing.

6. Watchdog (ping / pong) ​

Every 10 seconds the runtime sends { "type": "ping" } to each ready plugin. The SDK auto-responds with { "type": "pong" } — you write no code for this. Miss 3 consecutive pings (30s) and the runtime force-kills the subprocess as hung.

A plugin that blocks the event loop (a long synchronous loop) can miss pongs and get killed. Keep handlers async and yield; offload heavy work or chunk it.

7. Crash, restart & quarantine ​

When a subprocess exits unexpectedly, the runtime restarts it on a backoff schedule: 1s → 2s → 5s → 15s → 60s. If a plugin crashes 5 times within 10 minutes it is quarantined — no further restarts until manual intervention. This stops a broken plugin from pinning CPU in a tight crash loop.

A graceful stop (below) or a clean exit does not count toward the crash budget.

8. Shutdown ​

On unload (server stop, plugin disable, runtime teardown) the runtime stops the plugin gracefully:

  1. Send SIGTERM, wait up to 5 seconds for a clean exit.
  2. SIGKILL if it hasn't exited.
  3. Close the transport and fire unload callbacks (managed services released, etc.).

To shut down cleanly, let your event loop drain — flush pending writes in handler paths, not in an exit hook, since SIGKILL after the grace window won't run one.

Reference: the message frames ​

You won't send these directly (the SDK does), but they're useful when reading logs or debugging:

FrameDirectionMeaning
readyplugin → runtimeSDK initialized; begin routing.
serve_readyplugin → runtimeCaches warm; un-grey sidebar items.
ping / pongruntime ⇄ pluginWatchdog heartbeat (auto-handled).
request / responseruntime ⇄ pluginA handler invocation and its result.
event.deliver / event.ackruntime ⇄ pluginEvent bus delivery + acknowledgement.

Full protocol: IPC protocol.