---
slug: 72-wasm
title: WebAssembly
description: Synsema as WebAssembly — the wasip1 artifact for confidential jobs, TEEs and wasmtime, and the embeddable artifact for browsers, Node/Bun, Python, Go and edge runtimes, where your app lends the program http/kv/llm under the same deny-by-default capability model.
example_ids: []
---

# WebAssembly

Since v0.6.0 the interpreter also ships as **WebAssembly**. Same language, same
`.syn` files, same deny-by-default capabilities — what changes is *who runs the program*.

## Which one do I need?

- **To run Synsema on a machine or a server** — `synsema run`, `serve`, `test`, `init` —
  you want the **native binary**, not wasm: `curl -fsSL https://synsema.org/install.sh | sh`,
  or `npm i -g synsema` (the same binary, shipped through npm). See [Quickstart](00-quickstart).
- **To run Synsema programs *inside your own app* written in JavaScript/TypeScript, Python
  or Go** — in the browser, in Node, in an edge runtime — you want the **embeddable
  artifact**: the npm package `@synsema/wasm` (or the same `.wasm` with the Python/Go glue).
  Your app hands the interpreter a program as text and gets its output back; your app also
  decides what the program may touch (network, storage, an LLM). [Below](#the-embeddable-artifact-agents-inside-apps-written-in-other-languages).
- **To run a `.syn` under a WASI host** — wasmtime, a TEE job runner, a confidential
  coprocessor — you want the **wasip1 artifact**: a command-line `.wasm` that behaves like
  `synsema run` without installing anything on the host. [Next section](#the-wasip1-artifact-confidential-jobs-tees).

Both artifacts are attached to every release (`synsema-wasm-wasip1.wasm`,
`synsema-wasm-web.wasm`, each with a `.sha256`); the embeddable one is also on npm. Nobody
installs Synsema on the host: the `.wasm` **is** the deployable unit, the way a container
image is. Try it without installing anything: the docs site's [playground](/play) runs the
interpreter in your browser.

## The wasip1 artifact: confidential jobs, TEEs

Download `synsema-wasm-wasip1.wasm` from the [release](https://github.com/kitecosmic/synsema/releases/latest)
(verify the `.sha256`) and run it with any WASI host:

```sh
# run a program (wasmtime)
wasmtime run --dir . synsema-wasm.wasm program.syn

# run a file's `test` blocks
wasmtime run --dir . synsema-wasm.wasm --test program.syn

# read the program from stdin; pass config/secrets as env
wasmtime run --env ETH_KEY=... synsema-wasm.wasm -   < program.syn

# host ceiling — the SAME flags and parser as `synsema run`
wasmtime run --dir . synsema-wasm.wasm --sandbox program.syn                    # ceiling = [stdout, time]
wasmtime run --dir . synsema-wasm.wasm --cap-set stdout,secret=ETH_* program.syn
```

`synsema-wasm [--test] [--sandbox | --cap-set <list>] [--version] <file.syn | ->`. The host
ceiling (`--sandbox` ≡ `[stdout, time]`; `--cap-set` = `name` or `name=scope`,
comma-separated) is the same defense-in-depth as in `synsema run`: a `require` above it is
denied, auto-grants included. An **unknown `--flag` is an error (exit 2), never silently
taken as the program path**. Exit codes: `0` ok, `1` runtime error / failing test, `2`
usage or unreadable program.

No wasmtime at hand? Node ships WASI: `node examples/embed/node/run-wasip1.mjs
synsema-wasm.wasm program.syn` runs the same artifact (Node still flags `node:wasi` as
experimental; it runs the whole binary).

A TEE job (a confidential coprocessor that runs WASM inside an enclave and records the
result onchain) is input → pure compute → verifiable output. That is this artifact: read
the input, compute, hash/sign the result with the key sealed as a `secret` under
`require sign`, print the output. The capability manifest doubles as the audit story.

```synsema
require secret("ETH_KEY")

let resumen be {"suma": sum([120, 180, 95])}
let cuerpo be json_encode(resumen)
let digest be decode(keccak256(cuerpo), "hex")
let addr be eth_address(secret("ETH_KEY"))
print(`resumen={cuerpo}`)
print(`keccak={digest}`)
print(`addr={addr}`)
```

## The embeddable artifact: agents inside apps written in other languages

In JavaScript/TypeScript it is an ordinary npm dependency — `package.json`, bundler,
types included (`index.d.ts`); nothing unusual:

```sh
npm i @synsema/wasm
```

```js
import { Synsema } from "@synsema/wasm";

const syn = await Synsema.load(new URL("@synsema/wasm/synsema.wasm", import.meta.url));
await syn.ready();

const r = syn.run(`print(keccak256("hola"))`, { env: { KEY: "abc" }, ceiling: "sandbox" });
r.output;   // ["…"]  the program's print lines
r.errors;   // []     parse/runtime errors are data, never exceptions
r.audit;    // every capability check, granted or not — see the fields below
```

Each `audit` entry is `{capability, granted, source, reason, origin}`. `reason` says **why** — a denial
is `No matching grant found` (the program never declared it), `above host ceiling
(--sandbox/--cap-set)` (declared, but above what the host lends) or `Explicitly denied by …`; a grant
is `Granted by <grant>`, an ambient one the runtime provided is `auto-granted by the runtime`, and a
read from a `synsema build` bundle is `bundled asset (part of the program)`. `origin` says **who** put
the entry there: `"program"` (a `require` of the program or a call it made) or `"runtime"` (an ambient
grant — `stdout`/`time`/`llm` in a non-secure run, `serve` from `--port`). Ambient grants that
**succeed** now leave a trace too (`source: "ambient"`, `granted: true`), so the audit shows exactly
what the runtime handed out, not only what it rejected. So "this tenant tried to read `STRIPE_KEY`" is
`origin == "program" && !granted`, with no need to re-parse the source. (The native `synsema run
--audit json` streams the same entries plus `ts`, `context` — which CapabilitySet, e.g.
`program`/`agent`/`request` — and `file`/`line`; and `run_program` adds `above parent ceiling` /
`above parent profile` for what a child asked beyond its parent — see [Observability](63-observability).)
The same distinction reaches the error text: a call over the ceiling says
*declared but above the host ceiling — the program cannot fix this; the host must widen the ceiling*,
while a missing `require` still says which line to add.

**Moving value in the browser/edge.** `sign`, `spend`, `wallet` and `reveal` are fail-loud: no audit
line, no operation. Natively that line goes to `~/.synsema/audit/<op>.log`; a wasm host has no
filesystem, so the line goes to the host's **`kv`** under the `audit` namespace (key = `sign.log`,
`spend.log`, `wallet.log`, `reveal.log`, append-only text, never key material). Offer `kv` and those
operations work exactly as natively — under the ceiling you set (`sign=ETH_KEY`, `spend=USD`,
`wallet=mnemonic*`); offer no `kv` and they refuse with *this host provides no audit sink*. A runaway
recursion is a runtime error (`maximum recursion depth exceeded`), never a trap that discards the
instance.

`run` → `{ok, output, errors, audit, llm_tokens}`; `test` → `{passed, failed, lines}`;
`check` → `{ok, errors}` (parse + the memory-declaration rule — at most one `require memory`, with a valid name; it does **not** flag a `remember` without `require memory`: that is denied at `run`, in `errors`/`audit`, exactly like native `synsema check`); `handle` (below);
`version`. `env` replaces the `.env`: `secret("KEY")`/`env("KEY")` resolve from it.
`ceiling` takes the same syntax as `--cap-set` (`"stdout,secret=ETH_*"`) or `"sandbox"`.

Vite, Next, Bun and Node all resolve `new URL("@synsema/wasm/synsema.wasm",
import.meta.url)` (bundlers copy the `.wasm` into the build; Node reads it from disk). A
single HTML page with no bundler can import the package from an ESM CDN such as
`https://esm.sh/@synsema/wasm` — fine for a demo, not for a project (pin your versions,
don't depend on a third-party CDN at runtime).

The program is the same `.syn` you would run natively — it arrives as **text**
(`syn.run(source)`), its `print` lines come back in `output`, and nothing runs as a
process: no stdout, no files. The same `.wasm` works from **Python** (`examples/embed/python`,
wasmtime-py) and **Go** (`examples/embed/go`, wazero, no CGO): it exports one entry
(`synsema_call`, JSON in / JSON out) and imports three host functions, so any runtime
that loads WebAssembly can drive it with ~80 lines of glue.

### What your app lends: `http`, `kv`, `llm`

The program keeps its manifest; **your app decides what it actually gets**. Nothing the
host lends is reachable without the program's `require`, and the embedder's `ceiling`
denies above what the host lends. Every check lands in `audit`.

```js
const store = new Map();
const host = {
  http: (req) => ({ status: 200, headers: [["content-type", "application/json"]], body: "{}" }),
  kv: {
    get: (ns, k) => store.get(ns + "/" + k) ?? null,
    set: (ns, k, v) => store.set(ns + "/" + k, v),
    delete: (ns, k) => store.delete(ns + "/" + k),
    list: (ns) => [...store.keys()].filter((x) => x.startsWith(ns + "/")).map((x) => x.slice(ns.length + 1)),
  },
  llm: (op, prompt) => ({ content: "…", tokens: 12 }),   // plug your SDK here
  log: (line) => console.log(line),
};

syn.run(`require memory("agenda")\nremember("preference", "dark mode", ["ui"])`, { host, filename: "agenda.syn" });
syn.run(`require memory("agenda")\nprint(recall(search="dark")[0]["content"])`, { host, filename: "agenda.syn" });
syn.run(`require net("api.example")\nprint(fetch("https://api.example/ping")["status"])`, { host });
syn.run(`require llm\nprint(reason about "the weather")\nprint(llm_usage())`, { host });
syn.run(`require llm\nprint(reason about "x")`, { host, ceiling: "stdout" });   // denied: the ceiling wins
```

- **`http(req)`** → `{status, headers, body}` or `{error}` — backs `fetch`/`http_*` and the
  blockchain read-side RPC (`eth_balance`, `solana_*`, `algorand_*`, `btc_*`). Called
  **after** the `net(host)` gate, with the same URL canonicalization as the native binary.
  `req` is `{method, url, headers, body, timeout}` (`body_base64` when binary).
- **`kv.get / set / delete / list(ns, key)`** — backs persistent agent memory
  (`remember`/`recall`/`forget_memory`, rules, progress) and `state_*`. Memory lives under
  the namespace `memory:<declared name>` (**the declared name is the identity**, as in the
  native `.db`); `state_*` under `state`. `memory_summary()` reports `Backend: host-kv`;
  `recall` searches by substring/tags, as the in-memory store does natively.
- **`llm(op, prompt)`** → `{content, tokens}` — backs `reason`/`decide`/`analyze`/`generate`;
  `llm_available()` becomes true and `llm_usage()` sums the tokens you report.
- **`log(line)`** — the runtime's warnings (`bare require reveal …`); a browser has no stderr.
- **`sleep(secs)`** (or `true` for the default pause) — `sleep()` and the RPC confirmation
  polls. A Worker blocks with `Atomics.wait`; a browser main thread cannot.

Without a hook, the builtin fails with the truth: `fetch: … this host provides no http
transport (wasm profile) — the embedder can offer one through the http host hook, or run
the program with the native synsema binary`; `memory "agenda" is declared but this host
provides no durable storage`; LLM ops fall back to the core's offline placeholders.

### Async hosts (browser `fetch`, IndexedDB, LLM SDKs)

The interpreter is synchronous. `runAsync`/`testAsync`/`handleAsync` run it in a Worker
and block on `Atomics.wait` over a `SharedArrayBuffer` while your Promises resolve on
the main thread — responses larger than the buffer travel in chunks. Node/Bun/Deno work
out of the box; browsers need cross-origin isolation (`Cross-Origin-Opener-Policy:
same-origin` + `Cross-Origin-Embedder-Policy: require-corp`). Without it, use the sync
API with sync hooks.

```js
const r = await syn.runAsync(program, {
  host: { async http(req) { const res = await fetch(req.url, { method: req.method }); return { status: res.status, headers: res.headers, body: await res.text() }; } },
});
await syn.close();
```

### `serve` without sockets: edge handler mode

Cloudflare Workers, Fastly Compute, Fermyon Spin, Vercel Edge all load a `.wasm` and call
a handler per request. `handle(source, request)` is that handler: your platform passes
the request, gets a response.

```js
const app = `require serve(8080)
serve on 8080
    auth with check_token
    errors with shape_error
    route "GET /hello/:name"
        give {"hi": params.name, "visits": state_incr("visits")}
    route "POST /items" requires auth
        give created({"by": request.user.id, "body": request.json})
    route "GET /doc/:id"
        give content(page([heading(1, "Doc"), prose(params.id)], {"title": "Doc"}))`;

const res = syn.handle(app, { method: "GET", path: "/hello/ana?x=1", headers: { accept: "application/json" }, body: "" }, { host });
res.status; res.content_type; res.headers; res.body;   // res.log = the handler's print lines
```

The program is prepared once per instance (parse, top-level, route table) and reused
across requests — only the request changes. Routes by specificity, `:param`/`*rest`,
`query`, `request` (`json`, `form`, `cookies`, `user`), `auth with` (token, or token +
request), `errors with`, 404/405 with `Allow`, `expect` → 400, `content()` negotiated by
suffix or `Accept`, `redirect`, `with_header`/`set_cookie`, collection pagination, and
`state_*` durable through your `kv` all work as in the native server — including the two
rules that keep it the same language: `require serve(port)` is still mandatory (the manifest,
not the socket), and every request runs on a **snapshot of the globals** (a `set` on a global
inside a handler does not persist to the next request; shared state goes through `state_*`). **Not in handler
mode** (the platform does them before calling you): `stream` (SSE) and `proxy to` answer
501, rate limits and `static` mounts are ignored (the mount logs a warning), `host`
blocks (vhosts) and `mount` of exported route groups are rejected with a clear error, and
TLS/ACME terminate at the host.

### Time, randomness, files, size

`now()` comes from the host clock (`Date.now()`, `time.time()`, `time.Now()`); `random()`,
`token()`, `mnemonic_generate` and every signature nonce come from the host's entropy
(`crypto.getRandomValues`, `os.urandom`, `crypto/rand`) — the cryptographic doctrine does
not change. There is no filesystem: `read_file` and friends fail saying so. The artifact
is ~7.5 MB (2.6 MB gzip), CI enforces a 5 MB gzip budget. A program error is data
(`errors[]`); a trap (an interpreter panic) discards the instance and the glue recreates it.

## The same language, an environment that grants less

The wasm profile is **not a dialect**. It is the full language in an environment that
grants only what the host lends — exactly what the deny-by-default model already
expresses. Included in both artifacts, byte-identical to the native binary (CI diffs the
probes under wasmtime and through the embed API under Node on every push): the full
language, tasks, types, `match`, `try/recover`, enums, modules, templates; the numeric
tower and arrays; text, regex, JSON, CSV, stats; charts and PNG/PDF export; hashing,
HMAC, `secret`; the whole pure blockchain side (`eth_address`, ABI, EIP-191/712,
`tx_eip1559`, Solana/Algorand encoding, Bitcoin builder/PSBT, gated `*_sign`, HD wallets);
web-auth pure side (password hashing, JWT, TOTP, `oidc_verify` with an inline JWKS);
`sandbox`, `intent`, per-tool capability scoping, the host ceiling; the response helpers
and the `content()` vocabulary; multi-agent (`agent`/`spawn`/`share`/`observe`/`signal`/
`wait_for`) in-process; `parallel_map`/`chunk` sequential (same order and fail-fast
semantics, no thread pool).

Also in both: `args()` (empty in the browser; the argv after the file in wasip1) and — new in
v0.6.14 — this same **pure profile** is a switch on the **native** binary too (`synsema run
--profile pure`), so you can run under the wall without wasm at all. See
[Sandboxing § the pure profile](22-sandbox).

**Not in either artifact** — the names exist and fail with the truth of the environment, never with
`Undefined variable`. The message is *`<name>: not available in the pure profile — <why> (<hint>)`*,
identical to `synsema run --profile pure` (the native pure hint says *drop --profile pure to run it
natively* instead of *run the program with the native `synsema` binary*):

| Family | Builtins | `<why>` |
|---|---|---|
| Filesystem, exec | `read_file`/`write_file`/`append_file`/`edit_file`/`list_dir`/`file_info`/`file_exists`/`grep`, `run` | `this run has no filesystem` / `… no child processes` (browser build; wasip1 has files via `--dir`) |
| WebSocket, TLS identity, hub, Web Push delivery | `ws_*`, `mtls_identity`, `push_send` (v0.6.15+ — `push_vapid_keys` is pure and stays), `select`/`proc_*`/`watch*`/`term_*` | `this run has no raw network sockets (WebSocket/TLS identity need an event loop and a process)` / `… no I/O hub` |
| Databases | `db_open`/`db_close`, `sql`/`sql_exec`/`sql_batch`/`sql_tables`/`paged`, `mongo_*`, `redis_*` | `this run has no database drivers` (in edge, reach D1/Neon/Upstash over `http`) |
| Cron | `cron_*` | `this run has no scheduler threads` (the host schedules; a job is invoked) |
| Process (wasm only) | `self_path`, `run_program` | `this run has no process` / `… no child processes` |
| Real threads (wasm only) | `spawn`, `parallel_map`, `bus_*`, `agents` | keep their semantics, run in-process / sequentially |

(`term_open` is the exception: it returns `nothing`, like a native run without a TTY, so the
`read_line` fallback is the same program. Under native `--profile pure` the thread/process families
stay — a native process has threads — so only filesystem/exec/sockets/db/cron are walled off, and
`fetch`/`http_*` with `net` still reach the network.)

## Building the artifacts from source

Only needed to hack on the engine itself — users take the `.wasm` from the release or npm.

```sh
rustup target add wasm32-wasip1 wasm32-unknown-unknown
cargo build --manifest-path engine/Cargo.toml -p synsema-wasm --target wasm32-wasip1 --profile wasm
cargo build --manifest-path engine/Cargo.toml -p synsema-wasm-web --target wasm32-unknown-unknown --profile wasm
# engine/target/wasm32-wasip1/wasm/synsema-wasm.wasm (~6.7 MB)
# engine/target/wasm32-unknown-unknown/wasm/synsema_wasm_web.wasm (~7.5 MB, 2.6 MB gzip)
```

`synsema-stdlib` has a `native` feature (on by default) gating the OS-facing modules;
without it the pure profile compiles to wasm32. The engine CI checks that profile, builds
both artifacts, diffs the pure probes against the native binary, and drives the
embeddable one from Node, Python and Go on every push.
