@package-system

Building for WASM

The build pipeline for sandboxed project packages.

Project packages with server-side runtime code execute inside an Extism WASM sandbox, never in the host process (see Lifecycle for the runtime tiers). You author a WASM package with the same node conventions a first-party package uses — src/tools/*.ts, src/routes/*.ts, an optional src/lifecycle.ts. The build generates the dispatcher, compiles it to the dist/package.wasm the sandbox loads, and emits a dist/routes.json route manifest. Declarative-only packages — tool schemas, skills, markdown, UI assets — need no build step at all.

When you need it

Missing pieceSymptom
No runtime.type: "wasm" in the manifestThe loader treats the package as declarative-only; handlers are never wired even if a .wasm exists
No src/tools/* / src/routes/* / src/lifecycle.ts sourceEvery build trigger fails with "No buildable source"
Source present but not built yetThe package loads with partial status (build-missing); tool calls return a clean error until built
src/routes/* present but no valid dist/routes.jsonThe loader fails closed — it loads partial rather than wiring an ungated route

The manifest half is one line:

{ "neuralis": { "runtime": { "type": "wasm" } } }

Do not add runtime.binding: "embedded" — embedded binding is first-party-only and the loader rejects the entire package. For project packages type: "wasm" alone is correct.

The entry contract — node conventions, generated dispatch

There is no hand-written src/index.ts / handleTool / handleRoute. You write per-file handlers; neuralis-build generates the dispatcher over the guest PDK. The tool name is the file name (it pairs with tools/<name>.json), and a route declares its pattern and optional feature.

// src/tools/echo.ts  → tool "echo" (pairs with tools/echo.json)
import type { PdkContext } from '@neuralis/package-system/pdk-guest';

export default async function (args: Record<string, unknown>, ctx: PdkContext) {
  // Route sugar over brain-core `POST read`, dispatched AS THE CURRENT CALLER —
  // their `drive.read` feature and the source's uri-policy decide, not the package.
  const res = ctx.fs.read('data://my-pkg/note.md');
  const note = res.ok && res.status === 200
    ? (res.body as { files?: { content?: string }[] }).files?.[0]?.content
    : undefined;
  return { content: [{ type: 'text', text: String(args.msg ?? note ?? '') }] };
}
// src/routes/items.ts
export const pattern = 'items/:id';      // :param + '*' wildcard, same matcher as node routes
export const feature = 'my-pkg.read';    // enforced HOST-side, BEFORE the sandbox is entered

export async function GET(req: { query: Record<string, string> }, state: unknown) {
  return { status: 200, body: { id: req.query.id } };
}

The native first-party ctx.config/ctx.platform/ctx.hostPorts and realtime-channel contracts are not guest authority. The ctx here is the guest PDK context (@neuralis/package-system/pdk-guest) — a flat, node-style surface: ctx.data.* (the package's own data dir), ctx.callRoute (cross-package route dispatch — see below), ctx.fs.* (thin sugar over brain-core's file routes, riding callRoute), ctx.context, ctx.log, ctx.config and ctx.emitEvent (both currently inert — config returns {} and emitEvent is a no-op), and ctx.session (the three ids the host forwards: userId/projectId/agentId). Identity binds per call, host-side: a capability captured in init still acts as whoever is calling now.

State is lazy and cached: src/lifecycle.ts's init(ctx) runs once on the first call and is reused for the instance's lifetime — never re-run, never mutated across calls. Persist durable state through the data dir (ctx.data), which the brain sync path chunks, embeds, and makes revertible.

ctx.data is confined to your own directory

All three data calls take a path relative to your package's data dir and are confined to it. A .. segment, an absolute path, and a symlink whose target lies outside all answer ok: false — they do not reach the file. ctx.context carries { packageId, trust } and deliberately no host path, since a path from outside your dir would be refused anyway.

Each call returns an ok-discriminated result — read adds content, list adds entries ({ name, isDirectory }), and a failure carries one of invalid_path, escape, not_found, not_file, not_directory, too_large (a single read is capped at 5 MB, above the guest memory budget anyway) or io_error. Never an operating-system message: those name real paths.

Rebuild artifacts built before this change

list used to return a bare array, which reported a refused path and an empty directory identically. Rebuild any artifact built before this change (neuralis-build) — the host function names did not move, so the ABI precheck cannot catch the difference for you.

Confined is the exact word, and it is not the same as governed: your data dir sits inside the data:// source, whose per-path policies this direct filesystem surface does not evaluate. Anything outside your own dir goes through ctx.callRoute / ctx.fs.*, where the target route's feature gate and uri-policy do apply.

Routes are feature-gated by the host

The build records each route's pattern and feature in dist/routes.json. The host reads it and runs the same requireFeature gate, rate-limit, and audit a node route gets — before the sandbox is entered. A caller missing the route's feature receives a 403 and your handler never runs, so you no longer check the feature inside the handler. See Routes and Features & access.

The pipeline

src/tools/*.ts + src/routes/*.ts + src/lifecycle.ts
  → generate   (handleTool/handleRoute dispatcher over the guest PDK)
  → esbuild    (generated entry + your source → bundled CJS)   dist/index.js
  → extism-js  (QuickJS + Wizer snapshot + Binaryen optimize)   dist/package.wasm
  → emit       (route manifest, extracted statically)           dist/routes.json

The TypeScript interface file passed to extism-js is a canonical plugin interface written by the builder itself (dist/plugin-interface.d.ts) — every Neuralis package exposes the same generated exports and the same host function surface, so one interface fits all. A user-authored declaration file is not read.

Calling other packages — ctx.callRoute

A trusted project package reaches the rest of the platform the same way a skill script or a widget does: through the packages' feature-gated HTTP routes — just in-process, with no HTTP hop.

const res = ctx.callRoute('@neuralis/brain-core', 'POST', 'search', {
  search_mode: 'grep', glob: '**/*.md', query: 'TODO',
});

The contract that makes this safe is identity: the dispatch carries the current caller's real session — threaded host-side per call, never named by the guest — so the target route's own feature gate, uri-policy layer, and rate limits decide deny-by-default, exactly as if the user had called the route themselves. Because the sandbox path never carries the interactive-human marker, guest writes land in the review queue (pending_review) by construction. An ok: false result is a bridge-level failure (cross_package_denied for untrusted, route_not_bridgeable, route_response_too_large, …); an ok: true carries the route's own status/body — a 403 from the feature gate arrives here.

Which routes exist, and which feature each needs, is documented per package — the route catalog lives on the API & usage pages: agent-core · brain-core · admin · machine-core — with the dispatch mechanism in Routes.

A few surfaces are HTTP-only by nature. Streaming/SSE responses come back route_not_bridgeable (the bridge cancels the stream first): agent-core stream, the workflow/* events sub-path, brain-core events. Binary bodies are rejected the same way: brain-core raw, and machine-core's session/:key/stream/* proxy when it returns stream content. Two adjacent shapes to know: brain-core upload is multipart-only, so in-process it simply answers ok: true, status: 400 ("multipart required") — use the JSON create route instead; and a proxy's plain-text error page is an ordinary bridgeable response (ok: true with the upstream status).

There is no host-function HTTP fetch. Guest network I/O is Extism's built-in Http.request, and it passes two independent gates. First the sandbox's own host allow-list, by trust: empty for untrusted, so every outbound call is blocked; unrestricted for trusted. Then — for the trusted requests that get past it — the platform's SSRF-guarded fetch, the same stack the built-in web tools use. A request that the policy refuses comes back as an ordinary HTTP status your handler can branch on; it is never thrown, so the package stays alive.

Only what reaches the platform fetch comes back as a status

The sandbox's host allow-list sits above the platform fetch, and it refuses by throwing — which terminates the plugin. So a URL it rejects kills the package until it is reloaded, rather than returning 403. That covers a non-http(s) scheme and any URL with an empty hostname (file:///etc/passwd is both), and it applies at every trust tier: even with the allow-list wide open, an empty hostname matches nothing. Validate the scheme and host in your own code before calling Http.request.

What the guard means in practice, because several shapes that used to work no longer do:

  • Private, loopback, link-local and cloud-metadata addresses are blocked — including the obfuscated spellings (0177.0.0.1, 2130706433, IPv4-mapped IPv6, NAT64), and localhost by name.
  • Only ports 80, 443, 8080 and 8443 are reachable. A public API on :9000 now fails — this is the most common surprise. The port is checked before the hostname is even resolved, so a refused port and a refused address look the same from the guest.
  • http and https only — but a different scheme, and any URL with an empty hostname, is stopped by the host allow-list above rather than by the policy, so it terminates the plugin instead of returning a status (see the callout). Credentials in the URL (https://user:pass@host/) are rejected by the policy, as a 403.
  • Redirects are capped at 3 and every hop is re-resolved and re-checked, so a public URL cannot redirect you into private space.
  • Responses are capped at 5 MB (413) and the whole call at 30 seconds (504) — the timeout covers the entire operation including redirects, not each hop.
  • Verbs are limited to GET, HEAD, POST, PUT, PATCH, DELETE and OPTIONS; anything else is 405.
  • Response headers are not exposed to the guest.
  • A default user-agent, accept and accept-language are sent unless your own headers override them (header names are matched case-insensitively). Framing headers you cannot set: host, content-length, transfer-encoding, connection and friends.
  • A refusal is a 403 with a deliberately generic body — the reason is written to the package log for the operator, not returned to the guest, so the status can never be used to probe which internal names exist.

Internal Neuralis services are still reached with callRoute and the ctx.fs.* sugar, never over HTTP.

Build triggers

TriggerWhen
Filesystem UI "Build Package" buttonRecommended — builds, reloads, and audits in one step
Package installAutomatic when the manifest declares runtime.type: "wasm" and source is present
CLI inside the containernode /neuralis/node_modules/.bin/neuralis-build <packageRoot>

Invoke the CLI by absolute path

npx neuralis-build does not work from a project's _packages/ directory: the data zone has no node_modules above it, so npx falls back to the public registry and fails with an E404. Always call the bin by its absolute path under /neuralis/node_modules/.bin/.

The file watcher reloads a package when files change but never rebuilds. After editing src/, run a build — the button or the CLI in the package directory — and the watcher picks up the new dist/package.wasm (or dist/routes.json) and swaps the module automatically.

Prerequisites and limits

extism-js is a native binary, not an npm package. The Neuralis Docker image bakes pinned, checksum-verified builds of extism-js, wasm-merge, and wasm-opt into the image at build time — there is nothing to install by hand. esbuild ships with @neuralis/package-system.

The sandbox enforces per-trust limits at runtime:

UntrustedTrusted
Call timeout30 s120 s
Host transfer arena8 MB32 MB
Worker JS heap32 MB128 MB
Network (Http.request)refused at the sandbox host allow-list, which is empty — and the refusal terminates the instance (below)any public host, through the platform's SSRF-guarded fetch (private/metadata addresses, non-standard ports and credential URLs refused as 403)
Cross-package routes (callRoute + the ctx.fs.* sugar)denied — no outbound surface at allallowed, as the current caller: feature-gated, uri-policy-gated, writes land pending_review
Own data dir (ctx.data.*)allowed — confined to that directoryallowed — confined to that directory

Neither memory row caps your module. The first bounds the host-side arena that arguments and results are copied through on their way across the boundary; the second bounds the worker thread's JavaScript heap. A WebAssembly module declares and owns its own linear memory, and nothing the platform passes sets a maximum on it — so do not read "8 MB" as your heap budget. What those numbers actually limit is how much can cross in one call, and you will meet the 5 MB per-read cap first. The arena limit also fails soft: past it an allocation comes back as a null address rather than raising, so check the offset you get back.

"Blocked" understates what an untrusted Http.request does, and the difference matters when you are writing one. The host allow-list refuses by raising inside the host function, and that exception closes the plugin: the worker is terminated and the whole package stops serving until an admin reloads it. You do not get a catchable error or a failed response — the instance is gone, and every later tool call fails with it. Do not call it from an untrusted package to "see what happens".

At the trusted tier a refusal is ordinary: the guarded fetch never throws, it answers a synthetic status you can branch on.

The other build mode — neuralis-build ui (first-party UI)

The same CLI has a second mode that has nothing to do with the sandbox: it packages a host-assigned first-party package's workspace React UI as ONE prebuilt browser module the host attaches at runtime.

neuralis-build ui [packageRoot]

It bundles app/host.tsx (or app/host.ts) — the file that exports your install…HostComponents({ registry, port }) function — into dist/app/host.js plus code-split chunks/, a dist/app/host.css when the entry imports a stylesheet, and dist/app/shared-imports.json. Declare the result as app.module ({ "entry": "dist/app/host.js" }); the build fails if the declaration and the output disagree.

  • React and the platform client library are never bundled. They — and any module another first-party package publishes — are read from the host's own instances at runtime. If your bundle would still pull in a copy through a deep or relative path, the build fails with BUNDLED_SHARED_MODULE: two Reacts in one page is a broken workspace, not a warning.
  • Utility classes are not compiled into your module. The host compiles one stylesheet across every first-party package's UI sources, so keep your className-bearing code under app/.
  • Fonts and images are inlined as data URLs: the host serves only .js, .mjs and .css from dist/app/.

The WASM mode stays the default — neuralis-build [packageRoot] without ui builds the sandbox module described above.

The worked example

example-project — one of the three contract examples — is this path in full. Two of its properties are the point:

  • It ships source only, with no dist/package.wasm. Drop it into a project's _packages/ and the loader reports partial with a build-missing reason until you run the build — the honest state of every unbuilt project package.
  • It ships a skills/ folder and deliberately omits runtime.skillScripts. Below the trusted tier that field is a load error: an untrusted package may only declare "none". So a _packages/ drop may ship a SKILL.md, but its scripts/ will never spawn — the skill is prose the agent reads, not a shell entry point.

Troubleshooting

  • "No buildable source" — add at least one of src/tools/*.ts, src/routes/*.ts, or src/lifecycle.ts, and confirm the manifest declares runtime.type: "wasm".
  • "embedded binding requires first-party trust" — remove runtime.binding: "embedded"; project packages declare only runtime.type.
  • inputSchema.$schema must be JSON Schema draft-07 — tools/*.json must declare "$schema": "http://json-schema.org/draft-07/schema#". This is enforced at contribution validation (load), not only at dispatch.
  • A route 403s for everyone / 503s after build — a 403 means the caller lacks the route's feature (the host gate); a 503 with a "no valid routes.json" log means the build did not emit the manifest — rebuild (fail-closed: an unbuilt route is never wired ungated).
  • Tool advertised but calls fail — the package loaded partial (build-missing) or errored; check the server logs for the load result and rebuild.
  • The package builds clean but never appears in the catalog at all (not even partial or errored) — a tools/*.json failed validation, which currently aborts the whole package load. Check annotations.category is one of read, write, execute, network, machine, credential, admin, unknown, and that x-neuralis.operation (dotted path) and transport (one of mcp, embedded, http, stdio, remote) are well-formed. This validation error currently surfaces only in the server logs.
  • Build timeout — the extism-js step is capped at 30 seconds; check for circular dependencies and avoid bundling large libraries.

A successful build leaves four artifacts in dist/: the intermediate index.js bundle (generated dispatcher + your source), the builder-written plugin-interface.d.ts, the final package.wasm, and the routes.json route manifest the host reads to feature-gate WASM routes.

On this page