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 piece | Symptom |
|---|---|
No runtime.type: "wasm" in the manifest | The loader treats the package as declarative-only; handlers are never wired even if a .wasm exists |
No src/tools/* / src/routes/* / src/lifecycle.ts source | Every build trigger fails with "No buildable source" |
| Source present but not built yet | The package loads with partial status (build-missing); tool calls return a clean error until built |
src/routes/* present but no valid dist/routes.json | The 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.jsonThe 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), andlocalhostby name. - Only ports 80, 443, 8080 and 8443 are reachable. A public API on
:9000now 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. httpandhttpsonly — 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 a403.- 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,DELETEandOPTIONS; anything else is405. - Response headers are not exposed to the guest.
- A default
user-agent,acceptandaccept-languageare sent unless your own headers override them (header names are matched case-insensitively). Framing headers you cannot set:host,content-length,transfer-encoding,connectionand friends. - A refusal is a
403with 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
| Trigger | When |
|---|---|
| Filesystem UI "Build Package" button | Recommended — builds, reloads, and audits in one step |
| Package install | Automatic when the manifest declares runtime.type: "wasm" and source is present |
| CLI inside the container | node /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:
| Untrusted | Trusted | |
|---|---|---|
| Call timeout | 30 s | 120 s |
| Host transfer arena | 8 MB | 32 MB |
| Worker JS heap | 32 MB | 128 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 all | allowed, as the current caller: feature-gated, uri-policy-gated, writes land pending_review |
Own data dir (ctx.data.*) | allowed — confined to that directory | allowed — 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 underapp/. - Fonts and images are inlined as data URLs: the host serves only
.js,.mjsand.cssfromdist/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 reportspartialwith abuild-missingreason until you run the build — the honest state of every unbuilt project package. - It ships a
skills/folder and deliberately omitsruntime.skillScripts. Below thetrustedtier that field is a load error: an untrusted package may only declare"none". So a_packages/drop may ship aSKILL.md, but itsscripts/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, orsrc/lifecycle.ts, and confirm the manifest declaresruntime.type: "wasm". - "embedded binding requires first-party trust" — remove
runtime.binding: "embedded"; project packages declare onlyruntime.type. inputSchema.$schema must be JSON Schema draft-07—tools/*.jsonmust 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
partialor errored) — atools/*.jsonfailed validation, which currently aborts the whole package load. Checkannotations.categoryis one ofread,write,execute,network,machine,credential,admin,unknown, and thatx-neuralis.operation(dotted path) andtransport(one ofmcp,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.