@package-system

Lifecycle and trust tiers

How packages execute: in-process, WASM sandbox, or MCP — and what each trust tier may do.

A package's runtime.type decides how its code executes. Most packages don't need runtime code at all — tool schemas and markdown contributions are discovered declaratively — but when a package needs handlers, routes, or stateful services, it picks one of three contracts.

RuntimeWhoHow it runs
nodeFirst-party builtins onlyIn-process: lifecycle module + per-file route/tool handlers
wasmProject packages (JS/TS)Extism WASM sandbox in a worker thread, single entry module
mcpFirst-party builtins onlySubprocess speaking MCP over stdio, as the host user
(omitted)Declarative-only packagesNo code execution at all

wasm is the only code runtime a project package can use. Both node and mcp require first-party trust — one runs in the host process, the other spawns a child as the host user — so a project package declaring either does not run: its declarative contributions still register, but no subprocess is started and every handler call returns a clean runtime-unavailable error. The package reports partial with the reason (untrusted-node or untrusted-mcp), which is a demotion rather than a rejection so the declarative half keeps working.

Its compiled code is never even loaded. Discovery has two modes, and the host picks one from provenance it assigns itself — never from anything the package's own manifest claims. Only a first-party builtin is discovered in full mode, where dist/src/routes/*.js, dist/src/tools/*.js and dist/src/lifecycle.js are imported to bind their handlers. Every other package root — project drops and any directory a source marks as containing packages — is discovered in metadata-only mode: those same files are listed, so the package is still catalogued and reported accurately as declaring node code, but they are never imported. This distinction matters because a JavaScript module's top-level code runs the moment it is imported, so refusing to call a handler is not by itself a boundary — refusing to load it is.

First-party node lifecycle

A node package with state ships a lifecycle module (src/lifecycle.ts, compiled to dist/src/lifecycle.js) exporting standalone functions. The module itself is optional — a package with no routes and no tool handlers needs none — but when it exists, init is required and everything else is optional.

import type { PackageInitContext } from '@neuralis/package-system';

type MyState = { store: ItemStore };
type MyApi = { getStatus: () => string };

export async function init(ctx: PackageInitContext): Promise<MyState> {
  const store = new ItemStore(ctx.data.fileStore('items'));
  return { store };
}

export function api(state: MyState): MyApi {
  return { getStatus: () => 'healthy' };
}

export async function start(state: MyState): Promise<void> { /* background work */ }
export async function stop(state: MyState): Promise<void> { /* cleanup */ }

The state init returns is captured once and handed back to every other export, so none of them re-derive it. The recognised exports are:

ExportCalledPurpose
init(ctx)Once at loadCreate and return the package's state. That state flows to route and tool handlers as their state argument.
api(state)Right after initBuild the public surface other packages reach through getPackageApi(). Omit it and the package exposes no cross-package API.
start(state)After all packages initializedStart background tasks.
onBootstrapComplete(state, ctx)After every package's init and start, before route servingStart work requiring the lazy ctx.platform facts: runtime stack, connector registries, URI policies, source declarations and host identity.
onCredentialChanged(state, event)After a credential write/deleteDrop matching cached clients; the value-free event carries id, scope and change, and the runtime bounds each hook.
drain(state)Before shutdown stopStop admitting work and finish or checkpoint in-flight work under the runtime shutdown bound.
health(state)Runtime health queryReturn structured checks with closed status/reason values; a throw, timeout or invalid report is degraded.
stop(state)ShutdownCleanup. The captured state is released afterwards.
provisionProject(state, ctx)When a project is createdIdempotent per-project data setup.
deprovisionProject(state, ctx)When a project is permanently deleted, before any of its filesRemove everything the package keeps for ctx.projectId outside the project's data tree (containers, volumes, index entries). Idempotent, and it must throw when something is left: the delete then stops with the project still archived, names the package, and can be retried. It runs under a timeout, one package after another.
onPrincipalRevoked(state, event)When a user is disabled, deleted or signed out everywhere, or loses a projectClose what the package holds open for event.userId (sockets, jobs, pollers) through its existing stops. It never decides access — the user is already refused everywhere — and it runs under a timeout.

Nothing else in the module is treated as a lifecycle export. Tool and route handlers are not declared here — they are discovered from src/tools/*.ts and src/routes/*.ts and wired into the package automatically.

PackageInitContext carries package identity, scoped data, host, cross-package packages, optional credential and credential-use ports, sessions.createProjectFactory, logger, live config, lazy platform facts and trust-gated hostPorts/config-write guards. Declared first-party settings are registered before init; ctx.config.get(key) throws for an unregistered key or a caller below first-party trust. Platform facts throw until published; read them at use time. Bootstrap completion carries this same platform accessor instead of copying aggregates.

The optional first-party hostPorts.packageDiagnostics reads current loaded definitions and bounded runtime health after successful readiness. Missing or not-ready diagnostics fail explicitly; they are not an empty catalogue. A package exposing this data must retain its route permissions and scope projection. Package maintenance mutations use their separate port and fresh permission checks.

A first-party package's data.globalDir is its project-independent platform home; project-installed packages receive only their scoped dataDir. Project factories are lazy, coalesce concurrent builds and bound idle entries through LRU eviction. A package retains the identity of infrastructure still serving active work and invalidates a permanently purged project after its writes drain.

Runtime provider and declared services

Exactly one builtin declares provides: ["runtime"] and exports runtimeProvider from dist/src/runtimeProvider.js; the host loads it natively and calls boot with kernel ports. runtime.whenReady() protects serving until bootstrap facts are published. Runtime loading, lifecycle fan-outs and realtime channel discovery remain package-owned.

Other first-party provides ids declare implementations through PackageRuntimeApi.services: session-ticket-verifier, agent-directory, channel-gateway, package-source-roots, and oauth-callback:<prefix>. For file-discovered init(ctx) → state, expose state.services; the adapter retains it separately from the public api(state). The runtime and ticket verifier are required singleton providers; other services are optional singletons, and OAuth prefixes must not overlap. Non-first-party declarations confer no service authority. mcp-server is a reserved id without an implementation slot.

Realtime channels

A first-party src/channels/<name>.ts, compiled to dist/src/channels/<name>.js, exports matching channel, non-empty feature, and subscribe(session, params, emit, state). It returns an unsubscribe function. The shared events hub gates the feature before subscribing; the package checks its own scope and event-delivery permissions. Channels are not model tools or markdown contributions.

A first-party src/events/<subject>.ts beside it exports matching subject, a required visible(event, session, state) that answers per recipient, and an optional bridge(publish, state) that starts once the package has booted and returns its unsubscribe; the package stops the bridge when it unloads. The declarations live in events[].

Writing to your own data directory

data is scoped to your package, and the scoping is enforced by the type system rather than by convention. jsonlAppender() returns a writer that takes a resolved path, not a string, and dataPath(relative) is what produces one:

const events = ctx.data.jsonlAppender<MyEvent>();
await events.append(ctx.data.dataPath('events.jsonl'), { kind: 'started' });
await events.append(ctx.data.dataPath('runs/today.jsonl'), { kind: 'ran' });

dataPath refuses anything that would leave your data directory — an absolute path, or a relative one that climbs out of it — so you cannot write into another package's data by accident. It resolves a path; it does not touch the disk. The appender creates any missing intermediate directories when it writes, so runs/today.jsonl works on a fresh data dir — but if you hand a dataPath result to your own fs call, the directories are yours to create.

Hold one appender for the life of your package. Its write ordering is per-instance: a fresh appender per call gives up the guarantee that entries written together stay together.

fileStore(subdir) is the record-shaped sibling: one JSON file per entity, with get, list, put, delete, exists — and update(id, fn), which is the one to reach for whenever the next value depends on the current one. A get → modify → put reads outside the store's write ordering, so two callers that both add a field compute from the same base and the second erases the first. update does the read inside it: fn receives the record as it is on disk at that moment (never a cached copy), returns the next record, or undefined to write nothing. It is synchronous by design — an await inside it would hold that record against every other writer. A missing record calls fn(null), so creating one is legitimate; any other read failure, and a corrupt record, are raised rather than overwritten.

The guest plane reports, it does not throw

A WASM-sandboxed package's ctx.data.read / write / list also take paths relative to its own data directory, but they return { ok: false, error } instead of throwing — their answers cross into guest memory, so they carry a fixed error code and never a filesystem path. See Building a WASM package.

Logging from a package

The injected logger writes structured JSONL under your package's own {dataDir}/logs/. For a first-party package it writes under {globalDir}/logs/ instead, because a package-wide log is platform data. The logger resolves its level at every call — your package's entry in the operator's packageLogLevelOverrides first (<package>=<level> pairs keyed on the pkg field of your log lines: _installed/<your-slug> for a project package), then the global packageLogLevel, then debug. Both are platform keys, so your manifest declares nothing for them. Never cache logger.level in a constructor: your lifecycle runs while the package is loading, before the host installs the config source, so a captured value stays pinned at the default.

Every line is appended, at a constant cost however large the file grows, so a log is oldest-first: read "the newest N" with JsonlAppender.readNewest(path, n), which takes the tail and then the rotated .1, .2 … generations, never the first lines of the file.

Reading a credential at runtime

ctx.credentials is the CredentialResolver the loader injects into every package. It is how a package reads a SECRET, and the resolver deliberately exposes no raw-environment escape hatch: there is no resolveEnv(key) on the port, so a package cannot ask the host to hand back an arbitrary environment variable. (Boot-critical infrastructure values still reach packages, but as typed config — a configSettings[] entry with an envFallback — never as an open env read.)

export async function init(ctx: PackageInitContext) {
  const creds = ctx.credentials;

  async function apiKey(projectId: string): Promise<string> {
    // Cascade-resolved most-specific-wins: agent → project → user → global.
    // Returns undefined when nothing is set at any reachable scope — a missing
    // credential is normal, so this never throws.
    const key = await creds.resolveCredential('acme.apiKey', { projectId });
    if (!key) throw new Error('acme.apiKey is not configured');
    return key;
  }

  return { state: { apiKey }, api: {} };
}

Three things to know before you build on it:

  • Resolve per call, not once at init. An owner can set or rotate a value at any time, and the scope that wins depends on who is calling. Capturing a key in a module constant pins one scope's value forever.
  • The id must be declared. resolveCredential reads whatever is stored, but an id no manifest declares never appears in the admin catalog, so an owner has no way to give it a value. Declare it in credentials[].
  • resolveForKind(kind, scope) is a dead stub. The host answers it with an empty object; it survives only as an anchor for a future kind→ids mapping. A package that builds on it silently gets nothing. Use resolveCredential.

A connector factory closes over this same port — the factory context carries no credentials map of its own.

Cross-package calls are typed APIs, not MCP

Packages call each other through getPackageApi<T>(packageId, session) — a typed, session-aware, trust-gated accessor. Every call carries a cross-package session (userId, projectId, callerId, callerTrust); without one, the target package's proxy denies the call. MCP is reserved for the model and external clients — internal MCP tool-calling between packages is forbidden by contract. See Sessions.

T is the provider's own canonical type wherever a dependency edge exists — importing it is what makes tsc check the call. Where an edge must not exist (a cycle, a package that deliberately does not depend on the provider, a generic host), the call site declares a small structural shape instead and pins it with assertApiMirror, which compiles that shape against the provider's shipped declaration — see Testing → Cross-package mirror drift. An unpinned structural shape drifts silently in both directions: an optional-chained member the provider renamed simply reads undefined, and one the mirror invented type-checks forever.

WASM sandbox (project packages)

A project package with runtime.type: "wasm" compiles to a sandboxed module. It is authored with the same per-file node conventions a first-party package uses — src/tools/*.ts (default-export handler), src/routes/*.ts (named GET/POST/… + export const pattern/feature), an optional src/lifecycle.ts — and neuralis-build GENERATES the handleTool/handleRoute dispatcher. There is no hand-written single entry.

// src/tools/catalog_list.ts → tool "catalog_list"
import type { PdkContext } from '@neuralis/package-system/pdk-guest';
export default async function (args: Record<string, unknown>, ctx: PdkContext) {
  return { content: [{ type: 'text', text: 'done' }] };
}
// src/routes/catalog.ts
export const pattern = 'catalog/:id';
export const feature = 'catalog.read';   // host enforces this BEFORE the sandbox
export async function GET(req: { query: Record<string, string> }) {
  return { status: 200, body: { id: req.query.id } };
}

The sandbox is stateless across calls — init runs lazily once and is cached for the instance; persist through the package data directory (ctx.data). A trusted package reaches the rest of the platform through ctx.callRoute — in-process dispatch of the other packages' feature-gated routes, as the current caller. The build pipeline (generate dispatcher → esbuild → extism-js → dist/package.wasm + dist/routes.json) and the guest PDK are covered in WASM build.

MCP runtime (non-JS first-party packages)

A package with runtime.type: "mcp" declares entry.command (plus optional args), and the host spawns it as a subprocess speaking the MCP protocol over stdio. This is the path for Python, Go, or any other non-JS implementation — the package's manifest, tools, and contributions follow the same contract as every other package.

It requires first-party trust. The child runs as the host user, with the host's filesystem and network reach, from a command the manifest names — so it is a language escape hatch, not a sandbox. A package below first-party that declares it loads partial / untrusted-mcp and spawns nothing.

The child's environment is built for it rather than inherited: secret-shaped names, known provider-prefixed variables and the platform's own infrastructure keys are stripped before the spawn. That is defence in depth, not a guarantee — it is a denylist over the host environment, so a variable whose name looks like nothing in particular still reaches the child. Keep secrets in the credential store.

If you just want to use an existing MCP server

This runtime is probably not what you want. It exists for shipping a package whose own implementation is non-JS — a much rarer thing than connecting to a server that already runs.

For a server reachable over HTTP, declare a connector instead (connectors[] with "type": "mcp" and a url — a different field entirely from runtime). Once connected it is dialed into the agent's MCP session and its tools become available with no code and no schema of your own.

A local stdio server (command) has no path today, at any tier. In-host stdio is deliberately forbidden — the transport factory refuses it and the session factory skips such an assignment with a warning — so only a url server is ever dialed. Note the sharp edge while that is true: a "type": "mcp" connector that omits transport defaults to stdio, which is the forbidden one, so a connector meant to reach an HTTP server must say so. Running a local non-JS server below first-party is therefore not currently supported by either route; the intended shape is a managed sidecar, which is not built yet.

Dispatch: trust-tier enforcement

Both dispatchers — tools and routes — enforce trust limits before any handler runs, keyed per package over a one-minute window:

TrustTool timeoutTool calls/minRoute calls/min
first-partynone enforcedunlimitedunlimited
trusted120 s100300
untrusted30 s1030

Tool dispatch additionally:

  • validates input against the tool's JSON Schema with an AJV validator, and fails closed on that path — a tool that declares an inputSchema never executes when the validator is unavailable or the declared schema is not an object. The guard is keyed on the schema being there, so the other side is worth stating: a catalogued tool with no inputSchema at all skips argument validation entirely and runs on whatever arrived. Ship a strict tools/*.json for every tool;
  • normalizes results to MCP shape, preserving text, image, audio, and resource content blocks verbatim and silently dropping every other block type;
  • keeps structuredContent and _meta intact — _meta is UI/runtime-only and is stripped before the model payload is assembled;
  • never throws: errors come back as isError: true results.

Result size bounding is applied once at the host's tool-call funnel so every package type — node, WASM, and MCP-bridged — passes through a single cap (see Tools).

Non-first-party route access is always audit-logged with the package id, trust tier, path, method, and caller scope.

Hot-reload & package sources

Project packages load from a directory and hot-reload on edit — drop or edit a package and its tools, skills, and contributions re-apply within seconds, with no manual rescan. The directory is watched through one shared chokidar-based file-event substrate; every change (including a watcher overflow) triggers a single debounced rescan and re-sync into the runtime, preserving every load guard (packages stay untrusted, reserved ids are rejected, trust never escalates on reload). A rescan — the watcher's or a manual one — re-reads every package directory of the project together, so it never drops a package another directory provides; and a package is reloaded only when its manifest or, for a WASM package, its built module changed — a rebuild is swapped in, an unchanged rescan reloads nothing.

A project loads runtime packages from two kinds of directory:

  • _packages/ — the default drop-zone, always active.
  • Any flagged source — a local-kind synced source can opt in by setting recognizesPackages: true in its source config. Its directory is then scanned and hot-reloaded exactly like _packages/. This is additive: the source still indexes its markdown contributions (skills/instructions/rules/ agents/docs) for source packages AND loads its WASM packages — a built-in dedup keeps the same package from appearing twice. Flagging a source is a code-load surface, so the write is owner/admin gated. A user- or agent-scoped source advertises its packages to that user or agent alone; per-owner runtime isolation of the package code is a later milestone.

When a package's contributions change, the host notifies connected MCP clients with tools/list_changed + prompts/list_changed (the server advertises listChanged capability), so a client re-lists and sees the new tools. The re-list re-applies the same feature/role gating, so a notification never widens what a caller can see.

On this page