Synsemadocsv0.6.xENES

Operate

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?§

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.

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.

coprocessor — you want the wasip1 artifact: a command-line .wasm that behaves like synsema run without installing anything on the host. Next section.

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 runs the interpreter in your browser.

The wasip1 artifact: confidential jobs, TEEs§

Download synsema-wasm-wasip1.wasm from the release (verify the .sha256) and run it with any WASI host:

# 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.

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:

npm i @synsema/wasm
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.) 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.

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

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).

(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_available() becomes true and llm_usage() sums the tokens you report.

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.

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.

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.

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):

FamilyBuiltins<why>
Filesystem, execread_file/write_file/append_file/edit_file/list_dir/file_info/file_exists/grep, runthis run has no filesystem / … no child processes (browser build; wasip1 has files via --dir)
WebSocket, TLS identity, hub, Web Push deliveryws_, 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
Databasesdb_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)
Croncron_*this run has no scheduler threads (the host schedules; a job is invoked)
Process (wasm only)self_path, run_programthis run has no process / … no child processes
Real threads (wasm only)spawn, parallel_map, bus_*, agentskeep 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.

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.