NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Services and persistence
Developer reference

Services and persistence

Services, persistence, and cleanup

Use ctx.dataDir() for plugin-owned writable files. The host chooses the persistent root and appends the plugin name. Do not infer another plugin's data directory or use it for Project files.

ctx.dataDir() creates the folder on the first call (src/plugins/registry.ts, dataDir). When the host passes no data root, as in tests, it falls back to the OS temporary directory. Call it inside a handler, not while register() runs.

Use ctx.db() only with reads: ["db"]. It is a narrowed handle over the main database. Without that grant, or when no database is wired into the process, ctx.db() throws at the call (src/plugins/registry.ts, db):

const db = ctx.db();

db.migrate([
  {
    version: 1,
    up(database) {
      database.exec(
        "CREATE TABLE IF NOT EXISTS p_my_plugin_items " +
        "(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
      );
    }
  }
]);

Migrations are ordered and run once per plugin. db.transaction(fn) runs a synchronous function atomically and returns only after commit; returning a Promise or other thenable is rejected and rolls back. appliedVersion() reports the highest applied version, or 0 when none has run. Keep plugin tables namespaced with p_<plugin>_. Do not add plugin-specific columns to core tables.

Migrations run only in the daemon process. The sub-agent runner opens the same database without migrating, so migrate logs a skip there and changes nothing (src/store/pluginDb.ts). Namespacing is a convention enforced in review, not by a runtime check (src/store/pluginDb.ts, file header).

Core's awaitable session/provider paths acquire the writer asynchronously through the same src/store/writeLock.ts boundary, then execute one uninterrupted synchronous transaction. Plugin db.transaction keeps its synchronous contract; an async callback must never suspend inside it. Slow synchronous plugin holds retain caller attribution in the owning process. Daemon /health.writerLock contains bounded operation timing aggregates with its pid; runner waits and holds are measured and logged by that runner. No plugin capability or API version changes with this core acquisition extension.

Cost and limits: every plugin transaction takes the one writer lock of the shared database, so keep transactions short. An async callback inside db.transaction is refused, not awaited (src/store/writeLock.ts, withWriteLock).

Register registerBootReconcile(fn) for idempotent repair on every daemon start. Register registerService({ name, start, stop }) for host-managed background work. Services start after boot reconciliation and stop when the daemon shuts down. registerInterval(name, fn, ms) uses an unref'd timer and is cleared on stop. Return asynchronous tick work: pending ticks are skipped, and stop drains the current tick. Failures report once at the start of a series and at most once per ten minutes; success logs recovery. A delegated runner loads plugin tools but does not start plugin services.

Boot reconciles run sequentially in registration order, each with a 20 second budget. A throw is logged and raises an admin alert, but it does not stop the boot or the reconciles after it (src/plugins/serviceRunner.ts, runBootReconciles). Without a registration, nothing runs at boot for that plugin. The sub-agent runner never runs reconciles. The sandbox plugin registers one as a real example (plugins/sandbox/index.mjs).

Services start in registration order after the reconciles. A start failure is logged and raises an alert; the remaining services still start. On shutdown, interval timers stop first, then services stop newest first, each with a 30 second grace period (src/plugins/serviceRunner.ts, startAll and shutdownAll). registerService refuses a registration with no name or with a start or stop that is not a function (src/plugins/registry.ts, registerService).

A throwing or rejected interval tick is logged, and the interval keeps running. Shutdown waits for ticks already in flight (src/plugins/registry.ts, registerInterval). A tick costs host time every ms milliseconds, not model calls, unless the tick starts a turn. The sandbox plugin registers a 10 second lease-reconcile interval as a real example (plugins/sandbox/index.mjs).

Register registerUserRemoved(fn) for every account-owned row, file, secret, or schedule. The account row still exists while the callback runs. Disabled plugins cannot receive the callback, so boot reconciliation must also make cleanup safe after re-enablement. Core verifies account process teardown and removes managed sessions before these callbacks run; a failing callback refuses account deletion so it can be retried. Terminal removes the account's remembered working directories and in-flight bookkeeping. Subagent removes retained jobs, workflow state and interrupted recovery journals by their exact account principal; late child events cannot recreate removed journals or publish completions. Other accounts remain untouched. No extra cleanup service or per-account storage copy is involved.

Registering this handler is mandatory for any plugin that stores per-user state. Nothing else reaps that state: the account id is never reused, so leftover files or rows stay on the instance forever (src/plugins/api.ts, registerUserRemoved). Core runs the handlers in registration order. A handler that throws is logged, the rest still run, and the deletion request returns an error while the account stays (src/api/routes/users.ts, account delete handler). The admin can repeat the request. The bundled changelog, MCP, sandbox, subagent and terminal plugins each register a handler as real examples (plugins/*/index.mjs).

Register registerProjectRemoved(fn) for Project-owned state. Make cleanup idempotent. The handler runs before the Project row is removed, so the id is still known. A throwing handler is logged and Project deletion continues (src/projects/projectService.ts, Project delete path).